cmxl 0.0.1
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/.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
|