mt940_parser 1.5.4 → 1.5.5
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 +4 -4
- data/VERSION +1 -1
- data/lib/mt940/customer_statement_message.rb +34 -23
- data/lib/mt940/version.rb +1 -1
- data/lib/mt940.rb +44 -42
- data/mt940_parser.gemspec +3 -3
- data/test/test_customer_statement_message.rb +27 -16
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 83c746f0f3871275400a75662a116760798fa3212f443588965c03cb53bfc7a5
|
4
|
+
data.tar.gz: f96ef50e6f342890620391ade43d1034a34df62e7320f95502b473abe9d71a3b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a9ba3439db4b3e5bb1595ac608c9060a453d0a0b3a3b56a4dd41e5f9eb68d017834932723ad574b0f07f0828935313dd966bec679de825060ca746fec2f84432
|
7
|
+
data.tar.gz: 2eacc5076f62817e7b1822426573ae8c2efddc6ecaf8af9d7642cb378490fd9d3d40c481ec7a3483c36077dc191eeac3aab5b05e6f28d0d6953256e379ab450d
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
1.5.
|
1
|
+
1.5.5
|
@@ -3,11 +3,10 @@
|
|
3
3
|
# the data easier
|
4
4
|
class MT940
|
5
5
|
class CustomerStatementMessage
|
6
|
-
|
7
|
-
attr_reader :account, :statement_lines
|
6
|
+
attr_reader :account, :statement_lines, :opening_balance, :closing_balance
|
8
7
|
|
9
8
|
def self.parse_file(file)
|
10
|
-
|
9
|
+
parse(File.read(file))
|
11
10
|
end
|
12
11
|
|
13
12
|
def self.parse(data)
|
@@ -16,12 +15,15 @@ class MT940
|
|
16
15
|
end
|
17
16
|
|
18
17
|
def initialize(lines)
|
19
|
-
@account = lines
|
18
|
+
@account = select_by_type(lines, MT940::AccountIdentification)
|
19
|
+
@opening_balance = select_by_type(lines, MT940::AccountBalance)
|
20
|
+
@closing_balance = select_by_type(lines, MT940::ClosingBalance)
|
20
21
|
@statement_lines = []
|
21
22
|
lines.each_with_index do |line, i|
|
22
23
|
next unless line.class == MT940::StatementLine
|
23
|
-
|
24
|
-
|
24
|
+
|
25
|
+
ensure_is_info_line!(lines[i + 1])
|
26
|
+
@statement_lines << StatementLineBundle.new(lines[i], lines[i + 1])
|
25
27
|
end
|
26
28
|
end
|
27
29
|
|
@@ -33,36 +35,45 @@ class MT940
|
|
33
35
|
@account.account_number
|
34
36
|
end
|
35
37
|
|
38
|
+
def signature
|
39
|
+
Digest::SHA256.hexdigest(opening_balance.content.to_s + closing_balance.content.to_s)
|
40
|
+
end
|
41
|
+
|
36
42
|
private
|
37
43
|
|
44
|
+
def select_by_type(lines, line_klass)
|
45
|
+
lines.select { |line| line.instance_of?(line_klass) }.first
|
46
|
+
end
|
47
|
+
|
38
48
|
def ensure_is_info_line!(line)
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
49
|
+
return if line.is_a? MT940::StatementLineInformation
|
50
|
+
|
51
|
+
raise Errors::UnexpectedStructureError,
|
52
|
+
'Unexpected Structure; expected StatementLineInformation, ' \
|
53
|
+
"but was #{line.class}"
|
44
54
|
end
|
45
55
|
end
|
46
56
|
|
47
57
|
class StatementLineBundle
|
48
58
|
METHOD_MAP = {
|
49
|
-
:
|
50
|
-
:
|
51
|
-
:
|
52
|
-
:
|
53
|
-
:
|
54
|
-
:
|
55
|
-
:
|
56
|
-
:
|
57
|
-
:
|
58
|
-
:
|
59
|
+
amount: :line,
|
60
|
+
funds_code: :line,
|
61
|
+
value_date: :line,
|
62
|
+
entry_date: :line,
|
63
|
+
account_holder: :info,
|
64
|
+
details: :info,
|
65
|
+
account_number: :info,
|
66
|
+
bank_code: :info,
|
67
|
+
code: :info,
|
68
|
+
transaction_description: :info
|
59
69
|
}
|
60
70
|
|
61
71
|
def initialize(statement_line, statement_line_info)
|
62
|
-
@line
|
72
|
+
@line = statement_line
|
73
|
+
@info = statement_line_info
|
63
74
|
end
|
64
75
|
|
65
|
-
def method_missing(method, *args, &
|
76
|
+
def method_missing(method, *args, &)
|
66
77
|
super unless METHOD_MAP.has_key?(method)
|
67
78
|
object = instance_variable_get("@#{METHOD_MAP[method.to_sym]}")
|
68
79
|
object.send(method)
|
data/lib/mt940/version.rb
CHANGED
data/lib/mt940.rb
CHANGED
@@ -4,6 +4,7 @@ require 'mt940/customer_statement_message'
|
|
4
4
|
require 'bigdecimal'
|
5
5
|
require 'bigdecimal/util'
|
6
6
|
require 'date'
|
7
|
+
require 'digest/sha2'
|
7
8
|
|
8
9
|
class MT940
|
9
10
|
class Field
|
@@ -13,12 +14,13 @@ class MT940
|
|
13
14
|
SHORT_DATE = /(\d{2})(\d{2})/
|
14
15
|
|
15
16
|
class << self
|
16
|
-
|
17
17
|
LINE = /^:(\d{2})(\w)?:(.*)$/
|
18
18
|
|
19
19
|
def for(line)
|
20
20
|
if line.match(LINE)
|
21
|
-
number
|
21
|
+
number = ::Regexp.last_match(1)
|
22
|
+
modifier = ::Regexp.last_match(2)
|
23
|
+
content = ::Regexp.last_match(3)
|
22
24
|
klass = {
|
23
25
|
'20' => Job,
|
24
26
|
'21' => Reference,
|
@@ -37,8 +39,8 @@ class MT940
|
|
37
39
|
klass.new(modifier, content)
|
38
40
|
else
|
39
41
|
raise Errors::WrongLineFormatError,
|
40
|
-
|
41
|
-
|
42
|
+
"Wrong line format does not match #{LINE.inspect}. Got: " \
|
43
|
+
"#{line.dump[0...80]}#{'[...]' if line.dump.size > 80}"
|
42
44
|
end
|
43
45
|
end
|
44
46
|
end
|
@@ -59,15 +61,14 @@ class MT940
|
|
59
61
|
|
60
62
|
def parse_date(date)
|
61
63
|
date.match(DATE)
|
62
|
-
::Date.new("20#{
|
64
|
+
::Date.new("20#{::Regexp.last_match(1)}".to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i)
|
63
65
|
end
|
64
66
|
|
65
67
|
def parse_entry_date(raw_entry_date, value_date)
|
66
68
|
raw_entry_date.match(SHORT_DATE)
|
67
|
-
entry_date = ::Date.new(value_date.year,
|
68
|
-
unless entry_date.year == value_date.year
|
69
|
-
|
70
|
-
end
|
69
|
+
entry_date = ::Date.new(value_date.year, ::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i)
|
70
|
+
raise 'Unhandled case: value date and entry date are in different years' unless entry_date.year == value_date.year
|
71
|
+
|
71
72
|
entry_date
|
72
73
|
end
|
73
74
|
end
|
@@ -88,20 +89,21 @@ class MT940
|
|
88
89
|
# 25
|
89
90
|
class AccountIdentification < Field
|
90
91
|
attr_reader :account_identifier
|
91
|
-
|
92
|
+
|
93
|
+
CONTENT = /(.{1,35})/ # any 35 chars (35x from the docs)
|
92
94
|
|
93
95
|
def parse_content(content)
|
94
96
|
content.match(CONTENT)
|
95
|
-
@account_identifier =
|
97
|
+
@account_identifier = ::Regexp.last_match(1)
|
96
98
|
end
|
97
99
|
|
98
100
|
# fail over to the old Account class
|
99
|
-
def method_missing(method, *args, &
|
101
|
+
def method_missing(method, *args, &)
|
100
102
|
@fail_over_implementation ||= Account.new(@modifier, @content)
|
101
103
|
value = @fail_over_implementation.send(method)
|
102
|
-
warn
|
104
|
+
warn '[DEPRECATION]:'
|
103
105
|
warn "You used '#{method}' on the Account/AccountIdentification class"
|
104
|
-
warn
|
106
|
+
warn 'This field is not part of the MT940 specification but implementation specific'
|
105
107
|
warn "Please use the 'account_identifier' and parse yourself."
|
106
108
|
|
107
109
|
value
|
@@ -113,11 +115,13 @@ class MT940
|
|
113
115
|
class Account < Field
|
114
116
|
attr_reader :bank_code, :account_number, :account_currency
|
115
117
|
|
116
|
-
CONTENT =
|
118
|
+
CONTENT = %r{^(.{8,11})/(\d{0,23})([A-Z]{3})?$}
|
117
119
|
|
118
120
|
def parse_content(content)
|
119
121
|
content.match(CONTENT)
|
120
|
-
@bank_code
|
122
|
+
@bank_code = ::Regexp.last_match(1)
|
123
|
+
@account_number = ::Regexp.last_match(2)
|
124
|
+
@account_currency = ::Regexp.last_match(3)
|
121
125
|
end
|
122
126
|
end
|
123
127
|
|
@@ -125,14 +129,15 @@ class MT940
|
|
125
129
|
class Statement < Field
|
126
130
|
attr_reader :number, :sheet
|
127
131
|
|
128
|
-
CONTENT =
|
132
|
+
CONTENT = %r{^(0|(\d{5,5})/(\d{2,5}))$}
|
129
133
|
|
130
134
|
def parse_content(content)
|
131
135
|
content.match(CONTENT)
|
132
|
-
if
|
136
|
+
if ::Regexp.last_match(1) == '0'
|
133
137
|
@number = @sheet = 0
|
134
138
|
else
|
135
|
-
@number
|
139
|
+
@number = ::Regexp.last_match(2).to_i
|
140
|
+
@sheet = ::Regexp.last_match(3).to_i
|
136
141
|
end
|
137
142
|
end
|
138
143
|
end
|
@@ -155,23 +160,23 @@ class MT940
|
|
155
160
|
end
|
156
161
|
|
157
162
|
@sign =
|
158
|
-
case
|
163
|
+
case ::Regexp.last_match(1)
|
159
164
|
when 'C'
|
160
165
|
:credit
|
161
166
|
when 'D'
|
162
167
|
:debit
|
163
168
|
end
|
164
169
|
|
165
|
-
raw_date =
|
166
|
-
@currency =
|
167
|
-
@amount = parse_amount_in_cents(
|
170
|
+
raw_date = ::Regexp.last_match(2)
|
171
|
+
@currency = ::Regexp.last_match(3)
|
172
|
+
@amount = parse_amount_in_cents(::Regexp.last_match(4))
|
168
173
|
|
169
174
|
@date =
|
170
175
|
case raw_date
|
171
176
|
when 'ALT', '0'
|
172
177
|
nil
|
173
178
|
when DATE
|
174
|
-
::Date.new("20#{
|
179
|
+
::Date.new("20#{::Regexp.last_match(1)}".to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i)
|
175
180
|
end
|
176
181
|
end
|
177
182
|
end
|
@@ -180,15 +185,15 @@ class MT940
|
|
180
185
|
class StatementLine < Field
|
181
186
|
attr_reader :date, :entry_date, :funds_code, :amount, :swift_code, :reference, :transaction_description
|
182
187
|
|
183
|
-
CONTENT =
|
188
|
+
CONTENT = %r{^(\d{6})(\d{4})?(C|D|RC|RD)\D?(\d{1,12},\d{0,2})((?:N|F).{3})(NONREF|.{0,16})(?:$|//)(.*)}
|
184
189
|
|
185
190
|
def parse_content(content)
|
186
191
|
content.match(CONTENT)
|
187
192
|
|
188
|
-
raw_date =
|
189
|
-
raw_entry_date =
|
193
|
+
raw_date = ::Regexp.last_match(1)
|
194
|
+
raw_entry_date = ::Regexp.last_match(2)
|
190
195
|
@funds_code =
|
191
|
-
case
|
196
|
+
case ::Regexp.last_match(3)
|
192
197
|
when 'C'
|
193
198
|
:credit
|
194
199
|
when 'D'
|
@@ -199,10 +204,10 @@ class MT940
|
|
199
204
|
:return_debit
|
200
205
|
end
|
201
206
|
|
202
|
-
@amount = parse_amount_in_cents(
|
203
|
-
@swift_code =
|
204
|
-
@reference =
|
205
|
-
@transaction_description =
|
207
|
+
@amount = parse_amount_in_cents(::Regexp.last_match(4))
|
208
|
+
@swift_code = ::Regexp.last_match(5)
|
209
|
+
@reference = ::Regexp.last_match(6)
|
210
|
+
@transaction_description = ::Regexp.last_match(7)
|
206
211
|
|
207
212
|
@date = parse_date(raw_date)
|
208
213
|
@entry_date = parse_entry_date(raw_entry_date, @date) if raw_entry_date
|
@@ -228,17 +233,17 @@ class MT940
|
|
228
233
|
# 86
|
229
234
|
class StatementLineInformation < Field
|
230
235
|
attr_reader :code, :transaction_description, :prima_nota, :details, :bank_code, :account_number,
|
231
|
-
|
236
|
+
:account_holder, :text_key_extension, :not_implemented_fields
|
232
237
|
|
233
238
|
def parse_content(content)
|
234
239
|
content.match(/^(\d{3})((.).*)$/)
|
235
|
-
@code =
|
240
|
+
@code = ::Regexp.last_match(1).to_i
|
236
241
|
|
237
242
|
details = []
|
238
243
|
account_holder = []
|
239
244
|
|
240
|
-
if seperator =
|
241
|
-
sub_fields =
|
245
|
+
if seperator = ::Regexp.last_match(3)
|
246
|
+
sub_fields = ::Regexp.last_match(2).scan(
|
242
247
|
/#{Regexp.escape(seperator)}(\d{2})([^#{Regexp.escape(seperator)}]*)/
|
243
248
|
)
|
244
249
|
|
@@ -260,10 +265,8 @@ class MT940
|
|
260
265
|
@text_key_extension = content
|
261
266
|
else
|
262
267
|
@not_implemented_fields ||= []
|
263
|
-
@not_implemented_fields << [
|
264
|
-
if $DEBUG
|
265
|
-
warn "code not implemented: code:#{code} content: #{content.inspect}"
|
266
|
-
end
|
268
|
+
@not_implemented_fields << [code, content]
|
269
|
+
warn "code not implemented: code:#{code} content: #{content.inspect}" if $DEBUG
|
267
270
|
end
|
268
271
|
end
|
269
272
|
end
|
@@ -287,8 +290,7 @@ class MT940
|
|
287
290
|
|
288
291
|
def parse_sheet(sheet)
|
289
292
|
lines = sheet.split("\r\n")
|
290
|
-
|
291
|
-
fields
|
293
|
+
lines.reject { |line| line.empty? }.map { |line| Field.for(line) }
|
292
294
|
end
|
293
295
|
end
|
294
296
|
end
|
data/mt940_parser.gemspec
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
2
|
-
# stub: mt940_parser 1.5.
|
2
|
+
# stub: mt940_parser 1.5.5 ruby lib
|
3
3
|
|
4
4
|
Gem::Specification.new do |s|
|
5
5
|
s.name = "mt940_parser".freeze
|
6
|
-
s.version = "1.5.
|
6
|
+
s.version = "1.5.5"
|
7
7
|
|
8
8
|
s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
|
9
9
|
s.require_paths = ["lib".freeze]
|
10
10
|
s.authors = ["Thies C. Arntzen".freeze, "Phillip Oertel".freeze]
|
11
|
-
s.date = "
|
11
|
+
s.date = "2025-05-21"
|
12
12
|
s.description = "Ruby library that parses account statements in the SWIFT MT940 format.".freeze
|
13
13
|
s.email = "developers@betterplace.org".freeze
|
14
14
|
s.extra_rdoc_files = ["README.md".freeze, "lib/mt940.rb".freeze, "lib/mt940/customer_statement_message.rb".freeze, "lib/mt940/errors.rb".freeze, "lib/mt940/version.rb".freeze, "lib/mt940_parser.rb".freeze]
|
@@ -1,22 +1,20 @@
|
|
1
1
|
require_relative 'test_helper'
|
2
2
|
|
3
|
-
|
4
3
|
# $DEBUG = true
|
5
4
|
class TestCustomerStatementMessage < Test::Unit::TestCase
|
6
|
-
|
7
5
|
def setup
|
8
|
-
file = File.dirname(__FILE__) +
|
6
|
+
file = File.dirname(__FILE__) + '/fixtures/sepa_snippet.txt'
|
9
7
|
messages = MT940::CustomerStatementMessage.parse_file(file)
|
10
8
|
@message = messages.first
|
11
9
|
@message_2 = messages.last
|
12
10
|
end
|
13
11
|
|
14
12
|
def test_it_should_know_the_bank_code
|
15
|
-
assert_equal
|
13
|
+
assert_equal '50880050', @message.bank_code
|
16
14
|
end
|
17
15
|
|
18
16
|
def test_it_should_know_the_account_number
|
19
|
-
assert_equal
|
17
|
+
assert_equal '0194787400888', @message.account_number
|
20
18
|
end
|
21
19
|
|
22
20
|
def test_it_should_have_an_account_identification
|
@@ -30,7 +28,7 @@ class TestCustomerStatementMessage < Test::Unit::TestCase
|
|
30
28
|
|
31
29
|
def test_statement_lines_should_have_amount_info_credit
|
32
30
|
line = @message.statement_lines.first
|
33
|
-
assert_equal
|
31
|
+
assert_equal 5_099_005, line.amount
|
34
32
|
assert_equal :credit, line.funds_code
|
35
33
|
end
|
36
34
|
|
@@ -47,12 +45,12 @@ class TestCustomerStatementMessage < Test::Unit::TestCase
|
|
47
45
|
|
48
46
|
def test_statement_lines_info_should_have_bank_code
|
49
47
|
line = @message.statement_lines.first
|
50
|
-
assert_equal
|
48
|
+
assert_equal 'DRESDEFF508', line.bank_code
|
51
49
|
end
|
52
50
|
|
53
51
|
def test_statement_lines_info_should_have_account_number
|
54
52
|
line = @message.statement_lines.first
|
55
|
-
assert_equal
|
53
|
+
assert_equal 'DE14508800500194785000', line.account_number
|
56
54
|
end
|
57
55
|
|
58
56
|
def test_statement_lines_should_have_details
|
@@ -62,32 +60,45 @@ class TestCustomerStatementMessage < Test::Unit::TestCase
|
|
62
60
|
|
63
61
|
def test_statement_lines_should_have_an_entry_date
|
64
62
|
line = @message.statement_lines.first
|
65
|
-
assert_equal Date.parse(
|
63
|
+
assert_equal Date.parse('2007-09-04'), line.entry_date
|
66
64
|
end
|
67
65
|
|
68
66
|
def test_statement_lines_should_have_a_value_date
|
69
67
|
line = @message.statement_lines.first
|
70
|
-
assert_equal Date.parse(
|
68
|
+
assert_equal Date.parse('2007-09-07'), line.value_date
|
71
69
|
end
|
72
70
|
|
73
71
|
def test_parsing_the_file_should_return_two_message_objects
|
74
|
-
file = File.dirname(__FILE__) +
|
72
|
+
file = File.dirname(__FILE__) + '/fixtures/sepa_snippet.txt'
|
75
73
|
messages = MT940::CustomerStatementMessage.parse_file(file)
|
76
74
|
assert_equal 2, messages.size
|
77
|
-
assert_equal
|
78
|
-
assert_equal
|
75
|
+
assert_equal '0194787400888', messages[0].account_number
|
76
|
+
assert_equal '0194791600888', messages[1].account_number
|
79
77
|
end
|
80
78
|
|
81
79
|
def test_parsing_a_file_with_broken_structure_should_raise_an_exception
|
82
|
-
file = File.dirname(__FILE__) +
|
80
|
+
file = File.dirname(__FILE__) + '/fixtures/sepa_snippet_broken.txt'
|
83
81
|
assert_raise(MT940::Errors::UnexpectedStructureError) do
|
84
82
|
MT940::CustomerStatementMessage.parse_file(file)
|
85
83
|
end
|
86
84
|
end
|
87
85
|
|
88
86
|
def test_should_raise_method_missing_when_asking_statement_lines_for_unknown_stuff
|
89
|
-
file = File.dirname(__FILE__) +
|
87
|
+
file = File.dirname(__FILE__) + '/fixtures/sepa_snippet.txt'
|
88
|
+
message = MT940::CustomerStatementMessage.parse_file(file).first
|
89
|
+
assert_raise(NoMethodError) { message.statement_lines.first.foo }
|
90
|
+
end
|
91
|
+
|
92
|
+
def test_should_parse_opening_and_closing_saldo
|
93
|
+
file = File.dirname(__FILE__) + '/fixtures/sepa_snippet.txt'
|
94
|
+
message = MT940::CustomerStatementMessage.parse_file(file).first
|
95
|
+
assert_equal(message.opening_balance.amount, 76_665_649)
|
96
|
+
assert_equal(message.closing_balance.amount, 112_525_040)
|
97
|
+
end
|
98
|
+
|
99
|
+
def test_should_have_signature
|
100
|
+
file = File.dirname(__FILE__) + '/fixtures/sepa_snippet.txt'
|
90
101
|
message = MT940::CustomerStatementMessage.parse_file(file).first
|
91
|
-
|
102
|
+
assert_equal(message.signature, 'b16439633c7c52fed8e28f717f364e7ec1936f123aa56ab66b15662093cb60ca')
|
92
103
|
end
|
93
104
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: mt940_parser
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.5.
|
4
|
+
version: 1.5.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Thies C. Arntzen
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2025-05-21 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: gem_hadar
|