cmxl 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +136 -0
- data/Rakefile +7 -0
- data/cmxl.gemspec +24 -0
- data/lib/cmxl.rb +20 -0
- data/lib/cmxl/field.rb +78 -0
- data/lib/cmxl/fields/account_balance.rb +45 -0
- data/lib/cmxl/fields/account_identification.rb +12 -0
- data/lib/cmxl/fields/available_balance.rb +8 -0
- data/lib/cmxl/fields/closing_balance.rb +8 -0
- data/lib/cmxl/fields/reference.rb +21 -0
- data/lib/cmxl/fields/statement_details.rb +54 -0
- data/lib/cmxl/fields/statement_line.rb +47 -0
- data/lib/cmxl/fields/statement_number.rb +8 -0
- data/lib/cmxl/statement.rb +84 -0
- data/lib/cmxl/transaction.rb +91 -0
- data/lib/cmxl/version.rb +3 -0
- data/spec/fields/account_balance_spec.rb +41 -0
- data/spec/fields/account_identification_spec.rb +24 -0
- data/spec/fields/available_balance_spec.rb +16 -0
- data/spec/fields/closing_balance_spec.rb +15 -0
- data/spec/fields/reference_spec.rb +12 -0
- data/spec/fields/statement_details_spec.rb +40 -0
- data/spec/fields/statement_number_spec.rb +10 -0
- data/spec/fields/statment_line_spec.rb +19 -0
- data/spec/fields/unknown_spec.rb +9 -0
- data/spec/fixtures/lines/account_balance_credit.txt +1 -0
- data/spec/fixtures/lines/account_balance_debit.txt +1 -0
- data/spec/fixtures/lines/account_identification_iban.txt +1 -0
- data/spec/fixtures/lines/account_identification_legacy.txt +1 -0
- data/spec/fixtures/lines/available_balance.txt +1 -0
- data/spec/fixtures/lines/closing_balance.txt +1 -0
- data/spec/fixtures/lines/reference.txt +1 -0
- data/spec/fixtures/lines/statement_details.txt +1 -0
- data/spec/fixtures/lines/statement_line.txt +1 -0
- data/spec/fixtures/lines/statement_number.txt +1 -0
- data/spec/fixtures/mt940.txt +75 -0
- data/spec/mt940_parsing_spec.rb +44 -0
- data/spec/spec_helper.rb +30 -0
- data/spec/support/fixtures.rb +7 -0
- data/spec/transaction_spec.rb +28 -0
- metadata +155 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 471c7b0121f9cd95bafcfe16586d35b9cd9ec50a
|
4
|
+
data.tar.gz: 5694a0e0d8089bc656aae5baa3c923277e7ad8ed
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9a58700e14cda9fca9b787e70970630324800606f4e5652f588bcb1fa5f9d9f9f8e7f91269d98e7d2d5be67f002be64bb2fc54cfb06f47151f71df0640185157
|
7
|
+
data.tar.gz: a3e652b2dec634c5b1bfe9292a65ae83f72a18a0402ca7d3c8ca8b253d4b730f0ceb2367a22779f0968769e6720cb744e96de4a6a9f7147064da35b7fda639ae
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Michael Bumann
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,136 @@
|
|
1
|
+
# Cmxl - your friendly ruby MT940 parser
|
2
|
+
|
3
|
+
At Railslove we build a lot of banking and payment applications and work on integrating applications with banks and banking functionality.
|
4
|
+
Our goal is to making it easy with what sometimes seems complicated.
|
5
|
+
|
6
|
+
Cmxl is a friendly and extendible MT940 bank statement file parser that helps your extracting data from the bank statement files.
|
7
|
+
|
8
|
+
## What is MT940?
|
9
|
+
|
10
|
+
MT940 (MT = Message Type) is the SWIFT-Standard for the electronic transfer of bank statement files.
|
11
|
+
When integrating with banks you often get MT940 files as interface.
|
12
|
+
For more information have a look at the different [SWIFT message types](http://en.wikipedia.org/wiki/SWIFT_message_types)
|
13
|
+
|
14
|
+
At some point in the future MT940 file should be exchanged with newer XML documents - but banking institutions are slow so MT940 will stick around for a while.
|
15
|
+
|
16
|
+
## Reqirements
|
17
|
+
|
18
|
+
Cmxl is a pure ruby parser and has no gem dependencies.
|
19
|
+
|
20
|
+
* Ruby 1.9.3 or newer
|
21
|
+
|
22
|
+
## Installation
|
23
|
+
|
24
|
+
|
25
|
+
Add this line to your application's Gemfile:
|
26
|
+
|
27
|
+
gem 'cmxl'
|
28
|
+
|
29
|
+
And then execute:
|
30
|
+
|
31
|
+
$ bundle
|
32
|
+
|
33
|
+
Or install it yourself as:
|
34
|
+
|
35
|
+
$ gem install cmxl
|
36
|
+
|
37
|
+
## Usage
|
38
|
+
|
39
|
+
Simple usage:
|
40
|
+
|
41
|
+
```ruby
|
42
|
+
|
43
|
+
statements = Cmxl.parse(File.read('mt940.txt')) # parses the file and returns an array of statement objects
|
44
|
+
statements.each do |s|
|
45
|
+
puts s.reference
|
46
|
+
puts s.generation_date
|
47
|
+
puts s.opening_balance.amount
|
48
|
+
puts s.closing_balance.amount
|
49
|
+
puts.sha # SHA of the statement source - could be used as an unique identifier
|
50
|
+
|
51
|
+
s.transactions.each do |t|
|
52
|
+
puts t.information
|
53
|
+
puts t.description
|
54
|
+
puts t.entry_date
|
55
|
+
puts t.funds_code
|
56
|
+
puts t.credit?
|
57
|
+
puts t.debit?
|
58
|
+
puts t.sign # -1 if it's a debit; 1 if it's a credit
|
59
|
+
puts t.name
|
60
|
+
puts t.iban
|
61
|
+
puts t.sepa
|
62
|
+
puts t.sub_fields
|
63
|
+
puts t.reference
|
64
|
+
puts t.bank_reference
|
65
|
+
# ...
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
```
|
70
|
+
|
71
|
+
Every object responds to `to_h` and let's you easily convert the data to a hash.
|
72
|
+
|
73
|
+
#### A note about encoding and file wirednesses
|
74
|
+
|
75
|
+
You probably will encounter encoding issues (hey, you are building banking applications!).
|
76
|
+
We try to handle encoding and format wirednesses as much as possible.
|
77
|
+
If you have encoding issues you can pass encoding options to the `Cmxl.parse(<string>, <options hash>)` it accepts the same options as [String#encode](http://ruby-doc.org/core-2.1.3/String.html#method-i-encode)
|
78
|
+
If that fails try to motify the file before you pass it to the parser - and please create an issue.
|
79
|
+
|
80
|
+
### Custom field parsers
|
81
|
+
|
82
|
+
Because a lot of banks implement the MT940 format slightly different one of the design goals of this library is to be able to customize the field parsers.
|
83
|
+
Every line get parsed with a special parser. Here is how to write your own parser:
|
84
|
+
|
85
|
+
```ruby
|
86
|
+
|
87
|
+
# simply create a new parser class inheriting from Cmxl::Field
|
88
|
+
class MyFieldParser < Cmxl::Field
|
89
|
+
self.tag = 42 # define which MT940 tag your parser can handle. This will automatically register your parser and overwriting existing parsers
|
90
|
+
self.parser = /(?<world>.*)/ # the regex to parse the line. Use named regexp to access your match.
|
91
|
+
|
92
|
+
def upcased
|
93
|
+
self.data['world'].upcase
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
my_field_parser = MyFieldParser.parse(":42:hello from mt940")
|
98
|
+
my_field_parser.world #=> hello from MT940
|
99
|
+
my_field_parser.upcased #=> HELLO FROM MT940
|
100
|
+
my_field_parser.data #=> {'world' => 'hello from mt940'} - data is the accessor to the regexp matches
|
101
|
+
|
102
|
+
```
|
103
|
+
|
104
|
+
## Parsing issues? - please create an issue with your file
|
105
|
+
|
106
|
+
The Mt940 format often looks different for the different banks and the different countries. Especially the not strict defined fields are often used for custom bank data.
|
107
|
+
If you have a file that can not be parsed please open an issue. We hope to build a parser that handles most of the files.
|
108
|
+
|
109
|
+
|
110
|
+
## ToDo
|
111
|
+
|
112
|
+
* collect MT940 files from different banks and use them as example for specs
|
113
|
+
|
114
|
+
|
115
|
+
## Contributing
|
116
|
+
|
117
|
+
### Specs
|
118
|
+
We use rspec to test Cmxl. Run `rake` to execute the whole test suite.
|
119
|
+
|
120
|
+
1. Fork it ( http://github.com/railslove/cmxl/fork )
|
121
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
122
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
123
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
124
|
+
5. Create new Pull Request
|
125
|
+
|
126
|
+
## Credits and other parsers
|
127
|
+
|
128
|
+
Cmxl is inspired and borrows ideas from the `mt940_parser` by the great people at [betterplace](https://www.betterplace.org/).
|
129
|
+
|
130
|
+
other parsers:
|
131
|
+
* [betterplace/mt940_parser](https://github.com/betterplace/mt940_parser)
|
132
|
+
* [gmitrev/mt940parser](https://github.com/gmitrev/mt940parser)
|
133
|
+
|
134
|
+
------------
|
135
|
+
|
136
|
+
2014 - built with love by [Railslove](http://railslove.com). We have built quite a number of FinTech products. If you need support we are happy to help. Please contact us at team@railslove.com.
|
data/Rakefile
ADDED
data/cmxl.gemspec
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'cmxl/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "cmxl"
|
8
|
+
spec.version = Cmxl::VERSION
|
9
|
+
spec.authors = ["Michael Bumann"]
|
10
|
+
spec.email = ["michael@railslove.com"]
|
11
|
+
spec.summary = %q{Cmxl is your friendly MT940 bank statement parser}
|
12
|
+
spec.description = %q{Cmxl provides an extensible and customizable parser for the MT940 bank statement format.}
|
13
|
+
spec.homepage = "http://railslove.com"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_development_dependency "bundler", "~> 1.5"
|
22
|
+
spec.add_development_dependency "rake"
|
23
|
+
spec.add_development_dependency "rspec", '~>3.0'
|
24
|
+
end
|
data/lib/cmxl.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
require "cmxl/version"
|
2
|
+
|
3
|
+
require 'cmxl/field'
|
4
|
+
require 'cmxl/statement'
|
5
|
+
require 'cmxl/transaction'
|
6
|
+
Dir[File.join(File.dirname(__FILE__), 'cmxl/fields', '*.rb')].each { |f| require f; }
|
7
|
+
module Cmxl
|
8
|
+
|
9
|
+
STATEMENT_SEPARATOR = "\n-"
|
10
|
+
def self.parse(data, options={})
|
11
|
+
options[:universal_newline] ||= true
|
12
|
+
if options[:encoding]
|
13
|
+
data.encode!('UTF-8', options.delete(:encoding), options)
|
14
|
+
else
|
15
|
+
data.encode!('UTF-8', options) if !options.empty?
|
16
|
+
end
|
17
|
+
|
18
|
+
data.split(STATEMENT_SEPARATOR).reject { |s| s.strip.empty? }.collect {|s| Cmxl::Statement.new(s.strip) }
|
19
|
+
end
|
20
|
+
end
|
data/lib/cmxl/field.rb
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'date'
|
2
|
+
module Cmxl
|
3
|
+
class Field
|
4
|
+
class LineFormatError < StandardError; end
|
5
|
+
class Unknown < Field;
|
6
|
+
@parser= /(?<source>.*)/
|
7
|
+
def to_h
|
8
|
+
{ tag: tag, modifier: modifier, source: source }
|
9
|
+
end
|
10
|
+
end
|
11
|
+
DATE = /(?<year>\d{0,2})(?<month>\d{2})(?<day>\d{2})/
|
12
|
+
attr_accessor :source, :modifier, :match, :data, :tag
|
13
|
+
|
14
|
+
@@parsers = {}
|
15
|
+
@@parsers.default = Unknown
|
16
|
+
def self.parsers; @@parsers; end
|
17
|
+
|
18
|
+
class << self; attr_accessor :parser; end
|
19
|
+
def self.tag=(tag)
|
20
|
+
@tag = tag.to_s
|
21
|
+
@@parsers[tag.to_s] = self
|
22
|
+
end
|
23
|
+
def self.tag
|
24
|
+
@tag
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.parse(line)
|
28
|
+
if line.match(/^:(\d{2,2})(\w)?:(.*)$/)
|
29
|
+
tag, modifier, content = $1, $2, $3
|
30
|
+
Field.parsers[tag.to_s].new(content, modifier, tag)
|
31
|
+
else
|
32
|
+
raise LineFormatError, "Wrong line format: #{line.dump}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def initialize(source, modifier=nil, tag=nil)
|
37
|
+
self.tag = tag
|
38
|
+
self.modifier = modifier
|
39
|
+
self.source = source
|
40
|
+
self.data = {}
|
41
|
+
|
42
|
+
if self.match = self.source.match(self.class.parser)
|
43
|
+
self.match.names.each do |name|
|
44
|
+
self.data[name] = self.match[name]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def to_h
|
50
|
+
self.data
|
51
|
+
end
|
52
|
+
alias :to_hash :to_h
|
53
|
+
def to_json(*args)
|
54
|
+
to_h.to_json(*args)
|
55
|
+
end
|
56
|
+
|
57
|
+
def to_date(date, year=nil)
|
58
|
+
if match = date.match(DATE)
|
59
|
+
year ||= "20#{match['year']}"
|
60
|
+
month = match['month']
|
61
|
+
day = match['day']
|
62
|
+
Date.new(year.to_i, month.to_i, day.to_i)
|
63
|
+
else
|
64
|
+
date
|
65
|
+
end
|
66
|
+
rescue ArgumentError # let's simply ignore invalid dates
|
67
|
+
date
|
68
|
+
end
|
69
|
+
|
70
|
+
def method_missing(m, *value)
|
71
|
+
if m =~ /=\z/
|
72
|
+
self.data[m] = value.first
|
73
|
+
else
|
74
|
+
self.data[m.to_s]
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Cmxl
|
2
|
+
module Fields
|
3
|
+
class AccountBalance < Field
|
4
|
+
self.tag = 60
|
5
|
+
self.parser = /(?<funds_code>\A[a-zA-Z]{1})(?<date>\d{6})(?<currency>[a-zA-Z]{3})(?<amount>[\d|,|\.]{4,15})/i
|
6
|
+
|
7
|
+
def date
|
8
|
+
to_date(self.data['date'])
|
9
|
+
end
|
10
|
+
|
11
|
+
def credit?
|
12
|
+
self.data['funds_code'].to_s.upcase == 'C'
|
13
|
+
end
|
14
|
+
|
15
|
+
def debit?
|
16
|
+
!credit?
|
17
|
+
end
|
18
|
+
|
19
|
+
def amount
|
20
|
+
self.data['amount'].gsub(',','.').to_f * self.sign
|
21
|
+
end
|
22
|
+
|
23
|
+
def sign
|
24
|
+
self.credit? ? 1 : -1
|
25
|
+
end
|
26
|
+
|
27
|
+
def amount_in_cents
|
28
|
+
self.data['amount'].gsub(',', '').gsub('.','').to_i * self.sign
|
29
|
+
end
|
30
|
+
|
31
|
+
def to_h
|
32
|
+
{
|
33
|
+
'date' => date,
|
34
|
+
'funds_code' => funds_code,
|
35
|
+
'credit' => credit?,
|
36
|
+
'debit' => debit?,
|
37
|
+
'currency' => currency,
|
38
|
+
'amount' => amount,
|
39
|
+
'amount_in_cents' => amount_in_cents,
|
40
|
+
'sign' => sign
|
41
|
+
}
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module Cmxl
|
2
|
+
module Fields
|
3
|
+
class AccountIdentification < Field
|
4
|
+
self.tag = 25
|
5
|
+
self.parser = /(?<bank_code>\w{8,11})\/(?<account_number>\d{0,23})(?<currency>[A-Z]{3})?|(?<country>[a-zA-Z]{2})(?<ban>\d{11,36})/i
|
6
|
+
|
7
|
+
def iban
|
8
|
+
"#{self.country}#{self.ban}"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Cmxl
|
2
|
+
module Fields
|
3
|
+
class Reference < Field
|
4
|
+
self.tag = 20
|
5
|
+
self.parser = /(?<statement_identifier>[a-zA-Z]{0,2})(?<date>\d{6})(?<additional_number>.*)/i
|
6
|
+
|
7
|
+
def reference
|
8
|
+
self.source
|
9
|
+
end
|
10
|
+
|
11
|
+
def date
|
12
|
+
to_date(self.data['date'])
|
13
|
+
end
|
14
|
+
|
15
|
+
def to_h
|
16
|
+
super.merge({'date' => date, 'reference' => source})
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module Cmxl
|
2
|
+
module Fields
|
3
|
+
class StatementDetails < Field
|
4
|
+
self.tag = 86
|
5
|
+
self.parser = /(?<transaction_code>\w{3})(?<details>(?<seperator>.).*)/
|
6
|
+
|
7
|
+
def sub_fields
|
8
|
+
@sub_fields ||= self.data['details'].split(/#{Regexp.escape(self.data['seperator'])}(\d{2})/).reject(&:empty?).each_slice(2).to_h
|
9
|
+
end
|
10
|
+
|
11
|
+
def description
|
12
|
+
self.sub_fields['00']
|
13
|
+
end
|
14
|
+
|
15
|
+
def information
|
16
|
+
(20..29).to_a.collect {|i| self.sub_fields[i.to_s] }.join('')
|
17
|
+
end
|
18
|
+
|
19
|
+
def sepa
|
20
|
+
if self.information =~ /([A-Z]{4})\+/
|
21
|
+
self.information.split(/([A-Z]{4})\+/).reject(&:empty?).each_slice(2).to_h
|
22
|
+
else
|
23
|
+
{}
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def bic
|
28
|
+
self.sub_fields['30']
|
29
|
+
end
|
30
|
+
|
31
|
+
def name
|
32
|
+
"#{self.sub_fields['32']}#{self.sub_fields['33']}"
|
33
|
+
end
|
34
|
+
|
35
|
+
def iban
|
36
|
+
self.sub_fields['38'] || self.sub_fields['31']
|
37
|
+
end
|
38
|
+
|
39
|
+
def to_h
|
40
|
+
{
|
41
|
+
'bic' => bic,
|
42
|
+
'iban' => iban,
|
43
|
+
'name' => name,
|
44
|
+
'sepa' => sepa,
|
45
|
+
'information' => information,
|
46
|
+
'description' => description,
|
47
|
+
'sub_fields' => sub_fields,
|
48
|
+
'transaction_code' => transaction_code,
|
49
|
+
'details' => details
|
50
|
+
}
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|