bai2 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/.gitignore +14 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +102 -0
- data/Rakefile +7 -0
- data/bai2.gemspec +25 -0
- data/lib/bai2.rb +198 -0
- data/lib/bai2/attr-reader-from-ivar-hash.rb +31 -0
- data/lib/bai2/integrity.rb +128 -0
- data/lib/bai2/parser.rb +142 -0
- data/lib/bai2/record.rb +260 -0
- data/lib/bai2/type-code-data.rb +473 -0
- data/lib/bai2/version.rb +3 -0
- data/test/autorun.rb +3 -0
- data/test/data/daily.bai2 +8 -0
- data/test/data/eod.bai2 +17 -0
- data/test/tests/bai2.rb +59 -0
- data/test/tests/parsing.rb +9 -0
- metadata +124 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: b83aa7ebed34d3059973ec3d76e15f1e32d05814
|
4
|
+
data.tar.gz: fe4bec1ec7552da50e366d60113819c817dd92af
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1c318047c59369b2f709189e7efcbde12cc323368da2614e1b9d49d462be44f8a2f490e0b80c700aadb909001449640b0a1eaa4de09cd70d2b860d3f3fb2407e
|
7
|
+
data.tar.gz: 0eab9ce6627092f68b1749f2e2202f472706543c6728c578c8d8f6e490f2f06e759dff37ba7b3001e54059878ac53e68cd63a91b0532ba2a19b1e5eeb8e138ad
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 VentureHacks
|
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,102 @@
|
|
1
|
+
# Bai2
|
2
|
+
|
3
|
+
This library implements the [Bai2 standard][bai2], as per its official
|
4
|
+
specification.
|
5
|
+
|
6
|
+
[bai2]: http://www.bai.org/Libraries/Site-General-Downloads/Cash_Management_2005.sflb.ashx
|
7
|
+
|
8
|
+
## Installation
|
9
|
+
|
10
|
+
Add this line to your application's Gemfile:
|
11
|
+
|
12
|
+
```ruby
|
13
|
+
gem 'bai2'
|
14
|
+
```
|
15
|
+
|
16
|
+
And then execute:
|
17
|
+
|
18
|
+
$ bundle
|
19
|
+
|
20
|
+
Or install it yourself as:
|
21
|
+
|
22
|
+
$ gem install bai2
|
23
|
+
|
24
|
+
## Usage
|
25
|
+
|
26
|
+
`BaiFile` is the main class in gem.
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
# Parse a file:
|
30
|
+
file = Bai2::BaiFile.parse('file.bai2')
|
31
|
+
# Parse data:
|
32
|
+
file = Bai2::BaiFile.new(string_data)
|
33
|
+
|
34
|
+
puts file.sender, file.receiver
|
35
|
+
|
36
|
+
# e.g. filter for groups relevant to your organization, iterate:
|
37
|
+
file.groups.filter {|g| g.destination == YourOrgId }.each do |group|
|
38
|
+
|
39
|
+
# groups have accounts
|
40
|
+
group.accounts.each do |account|
|
41
|
+
|
42
|
+
puts account.customer, account.currency_code
|
43
|
+
|
44
|
+
# summaries are arrays of hashes
|
45
|
+
puts account.summaries.inspect
|
46
|
+
|
47
|
+
# accounts have transactions
|
48
|
+
|
49
|
+
# e.g. print all debits
|
50
|
+
account.transactions.filter(&:debit?).each do |debit|
|
51
|
+
|
52
|
+
# transactions have string amounts, too
|
53
|
+
puts debit.amount
|
54
|
+
|
55
|
+
# transaction types are represented by an informative hash:
|
56
|
+
puts debit.type
|
57
|
+
# => {
|
58
|
+
# code: 451,
|
59
|
+
# transaction: :debit,
|
60
|
+
# scope: :detail,
|
61
|
+
# description: "ACH Debit Received",
|
62
|
+
# }
|
63
|
+
|
64
|
+
puts debit.text
|
65
|
+
end
|
66
|
+
|
67
|
+
# e.g. print sum of all credits
|
68
|
+
sum = account.transactions \
|
69
|
+
.filter(&:credit?) \
|
70
|
+
.map(&:amount) \
|
71
|
+
.map {|a| BigDecimal(a) } \
|
72
|
+
.reduce(&:+)
|
73
|
+
puts sum.inspect
|
74
|
+
|
75
|
+
end
|
76
|
+
end
|
77
|
+
```
|
78
|
+
|
79
|
+
## Caveats
|
80
|
+
|
81
|
+
In `lib/bai2/integrity.rb`, we perform integrity checks mandated by the Bai2
|
82
|
+
standard. In our experience, the spec and bank’s real implementations differ on
|
83
|
+
how sums are calculated. It’s hard to tell if this an industry-wide trend, or
|
84
|
+
just an SVB quirk. I would love to hear how other banks do this. GitHub Issues
|
85
|
+
with more information on this would be greatly appreciated.
|
86
|
+
|
87
|
+
```ruby
|
88
|
+
# Check sum vs. summary + transaction sums
|
89
|
+
actual_sum = self.transactions.map(&:amount).reduce(0, &:+) \
|
90
|
+
#+ self.summaries.map {|s| s[:amount] }.reduce(0, &:+)
|
91
|
+
# TODO: ^ there seems to be a disconnect between what the spec defines
|
92
|
+
# as the formula for the checksum and what SVB implements...
|
93
|
+
```
|
94
|
+
|
95
|
+
|
96
|
+
## Contributing
|
97
|
+
|
98
|
+
1. Fork it ( https://github.com/venturehacks/bai2/fork )
|
99
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
100
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
101
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
102
|
+
5. Create a new Pull Request
|
data/Rakefile
ADDED
data/bai2.gemspec
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'bai2/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'bai2'
|
8
|
+
spec.version = Bai2::VERSION
|
9
|
+
spec.authors = ['Kenneth Ballenegger']
|
10
|
+
spec.email = ['kenneth@ballenegger.com']
|
11
|
+
spec.summary = %q{Parse BAI2 files.}
|
12
|
+
spec.description = %q{Parse BAI2 files.}
|
13
|
+
spec.homepage = 'https://github.com/venturehacks/bai2'
|
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.7'
|
22
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
23
|
+
spec.add_development_dependency 'minitest', '~> 5.5'
|
24
|
+
spec.add_development_dependency 'minitest-reporters', '~> 1.0'
|
25
|
+
end
|
data/lib/bai2.rb
ADDED
@@ -0,0 +1,198 @@
|
|
1
|
+
require 'bai2/version'
|
2
|
+
require 'bai2/record'
|
3
|
+
require 'bai2/parser'
|
4
|
+
require 'bai2/integrity'
|
5
|
+
require 'bai2/attr-reader-from-ivar-hash'
|
6
|
+
|
7
|
+
module Bai2
|
8
|
+
|
9
|
+
|
10
|
+
# This class is the main wrapper around a Bai2 file.
|
11
|
+
#
|
12
|
+
class BaiFile
|
13
|
+
|
14
|
+
# Parse a file on disk:
|
15
|
+
#
|
16
|
+
# f = BaiFile.parse('myfile.bai2')
|
17
|
+
#
|
18
|
+
def self.parse(path)
|
19
|
+
self.new(File.read(path))
|
20
|
+
end
|
21
|
+
|
22
|
+
|
23
|
+
# Parse a Bai2 data buffer:
|
24
|
+
#
|
25
|
+
# f = BaiFile.new(bai2_data)
|
26
|
+
#
|
27
|
+
def initialize(raw)
|
28
|
+
@raw = raw
|
29
|
+
@groups = []
|
30
|
+
parse(raw)
|
31
|
+
end
|
32
|
+
|
33
|
+
# This is the raw data. Probably not super important.
|
34
|
+
attr_reader :raw
|
35
|
+
|
36
|
+
# The groups contained within this file.
|
37
|
+
attr_reader :groups
|
38
|
+
|
39
|
+
|
40
|
+
# =========================================================================
|
41
|
+
# Record reading
|
42
|
+
#
|
43
|
+
|
44
|
+
extend AttrReaderFromIvarHash
|
45
|
+
|
46
|
+
# The transmitter and file recipient financial institutions.
|
47
|
+
attr_reader_from_ivar_hash :@header, :sender, :receiver
|
48
|
+
|
49
|
+
def file_creation_datetime
|
50
|
+
@header[:file_creation_date] + @header[:file_creation_time]
|
51
|
+
end
|
52
|
+
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
# This delegates most of the work to Bai2::Parser to build the ParseNode
|
57
|
+
# tree.
|
58
|
+
#
|
59
|
+
def parse(data)
|
60
|
+
|
61
|
+
root = Parser.parse(data)
|
62
|
+
|
63
|
+
# parse the file node; will descend tree and parse children
|
64
|
+
parse_file_node(root)
|
65
|
+
|
66
|
+
# assert integrity
|
67
|
+
assert_integrity!
|
68
|
+
end
|
69
|
+
|
70
|
+
|
71
|
+
# Parses the file_header root tree node, and creates the object hierarchy.
|
72
|
+
#
|
73
|
+
def parse_file_node(n)
|
74
|
+
|
75
|
+
unless n.code == :file_header && n.records.count == 2 && \
|
76
|
+
n.records.map(&:code) == [:file_header, :file_trailer]
|
77
|
+
raise ParseError.new('Unexpected record.')
|
78
|
+
end
|
79
|
+
|
80
|
+
@header, @trailer = *n.records
|
81
|
+
|
82
|
+
@groups = n.children.map {|child| Group.send(:parse, child) }
|
83
|
+
end
|
84
|
+
|
85
|
+
|
86
|
+
# =========================================================================
|
87
|
+
# Entities
|
88
|
+
#
|
89
|
+
|
90
|
+
public
|
91
|
+
|
92
|
+
class Group
|
93
|
+
extend AttrReaderFromIvarHash
|
94
|
+
|
95
|
+
def initialize
|
96
|
+
@accounts = []
|
97
|
+
end
|
98
|
+
|
99
|
+
attr_reader :accounts
|
100
|
+
|
101
|
+
attr_reader_from_ivar_hash :@header,
|
102
|
+
:destination, :originator, :currency_code, :group_status
|
103
|
+
|
104
|
+
def as_of_datetime
|
105
|
+
@header[:as_of_date] + @header[:as_of_time]
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
def self.parse(node)
|
110
|
+
self.new.tap do |g|
|
111
|
+
g.send(:parse, node)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def parse(n)
|
116
|
+
|
117
|
+
unless n.code == :group_header && \
|
118
|
+
n.records.map(&:code) == [:group_header, :group_trailer]
|
119
|
+
raise ParseError.new('Unexpected record.')
|
120
|
+
end
|
121
|
+
|
122
|
+
@header, @trailer = *n.records
|
123
|
+
|
124
|
+
@accounts = n.children.map {|child| Account.send(:parse, child) }
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|
128
|
+
|
129
|
+
|
130
|
+
class Account
|
131
|
+
extend AttrReaderFromIvarHash
|
132
|
+
|
133
|
+
def initialize
|
134
|
+
@transactions = []
|
135
|
+
end
|
136
|
+
|
137
|
+
attr_reader :transactions
|
138
|
+
|
139
|
+
attr_reader_from_ivar_hash :@header,
|
140
|
+
:customer, :currency_code, :summaries
|
141
|
+
|
142
|
+
private
|
143
|
+
def self.parse(node)
|
144
|
+
self.new.tap do |g|
|
145
|
+
g.send(:parse, node)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def parse(n)
|
150
|
+
|
151
|
+
unless n.code == :account_identifier && \
|
152
|
+
n.records.map(&:code) == [:account_identifier, :account_trailer]
|
153
|
+
raise ParseError.new('Unexpected record.')
|
154
|
+
end
|
155
|
+
|
156
|
+
@header, @trailer = *n.records
|
157
|
+
|
158
|
+
@transactions = n.children.map {|child| Transaction.parse(child) }
|
159
|
+
end
|
160
|
+
|
161
|
+
end
|
162
|
+
|
163
|
+
|
164
|
+
class Transaction
|
165
|
+
extend AttrReaderFromIvarHash
|
166
|
+
|
167
|
+
attr_reader_from_ivar_hash :@record,
|
168
|
+
:amount, :text, :type, :bank_reference, :customer_reference
|
169
|
+
|
170
|
+
def debit?
|
171
|
+
type[:transaction] == :debit
|
172
|
+
end
|
173
|
+
|
174
|
+
def credit?
|
175
|
+
type[:transaction] == :credit
|
176
|
+
end
|
177
|
+
|
178
|
+
private
|
179
|
+
def self.parse(node)
|
180
|
+
self.new.tap do |g|
|
181
|
+
g.send(:parse, node)
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def parse(n)
|
186
|
+
head, *rest = *n.records
|
187
|
+
|
188
|
+
unless head.code == :transaction_detail && rest.empty?
|
189
|
+
raise ParseError.new('Unexpected record.')
|
190
|
+
end
|
191
|
+
|
192
|
+
@record = head
|
193
|
+
end
|
194
|
+
|
195
|
+
end
|
196
|
+
|
197
|
+
end # BaiFile
|
198
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
|
2
|
+
module Bai2
|
3
|
+
private
|
4
|
+
|
5
|
+
# Helps define methods that simply read from a hash ivar. For example, imagine
|
6
|
+
# this class:
|
7
|
+
#
|
8
|
+
# class Person
|
9
|
+
# def initialize
|
10
|
+
# @info = {
|
11
|
+
# first_name: 'John',
|
12
|
+
# last_name: 'Smith',
|
13
|
+
# }
|
14
|
+
# end
|
15
|
+
# attr_reader_from_ivar_hash :@info, :first_name, :last_name
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# That last statement will automagically create methods `.first_name`, and
|
19
|
+
# `.last_name` on `Person`, which saves a whole bunch of typing :).
|
20
|
+
#
|
21
|
+
module AttrReaderFromIvarHash
|
22
|
+
|
23
|
+
def attr_reader_from_ivar_hash(ivar, *keys)
|
24
|
+
keys.each do |key|
|
25
|
+
define_method(key) do
|
26
|
+
(instance_variable_get(ivar) || {})[key]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,128 @@
|
|
1
|
+
|
2
|
+
module Bai2
|
3
|
+
|
4
|
+
class BaiFile
|
5
|
+
private
|
6
|
+
|
7
|
+
# =========================================================================
|
8
|
+
# Integrity verification
|
9
|
+
#
|
10
|
+
|
11
|
+
class IntegrityError < StandardError; end
|
12
|
+
|
13
|
+
# Asserts integrity of a fully-parsed BaiFile by calculating checksums.
|
14
|
+
#
|
15
|
+
def assert_integrity!
|
16
|
+
expectation = {
|
17
|
+
sum: @trailer[:file_control_total],
|
18
|
+
children: @trailer[:number_of_groups],
|
19
|
+
records: @trailer[:number_of_records],
|
20
|
+
}
|
21
|
+
|
22
|
+
# Check children count
|
23
|
+
unless expectation[:children] == (actual = self.groups.count)
|
24
|
+
raise IntegrityError.new("Number of groups invalid: " \
|
25
|
+
+ "expected #{expectation[:children]}, actually: #{actual}")
|
26
|
+
end
|
27
|
+
|
28
|
+
# Check sum vs. group sums
|
29
|
+
actual_sum = self.groups.map do |group|
|
30
|
+
group.instance_variable_get(:@trailer)[:group_control_total]
|
31
|
+
end.reduce(0, &:+)
|
32
|
+
|
33
|
+
unless expectation[:sum] == actual_sum
|
34
|
+
raise IntegrityError.new(
|
35
|
+
"Sums invalid: file: #{expectation[:sum]}, groups: #{actual_sum}")
|
36
|
+
end
|
37
|
+
|
38
|
+
# Run children assertions, which return number of records. May raise.
|
39
|
+
records = self.groups.map {|g| g.send(:assert_integrity!) }.reduce(0, &:+)
|
40
|
+
|
41
|
+
unless expectation[:records] == (actual = records + 2)
|
42
|
+
raise IntegrityError.new(
|
43
|
+
"Record count invalid: file: #{expectation[:records]}, groups: #{actual}")
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
|
48
|
+
public
|
49
|
+
|
50
|
+
|
51
|
+
class Group
|
52
|
+
private
|
53
|
+
|
54
|
+
# Asserts integrity of a fully-parsed BaiFile by calculating checksums.
|
55
|
+
#
|
56
|
+
def assert_integrity!
|
57
|
+
expectation = {
|
58
|
+
sum: @trailer[:group_control_total],
|
59
|
+
children: @trailer[:number_of_accounts],
|
60
|
+
records: @trailer[:number_of_records],
|
61
|
+
}
|
62
|
+
|
63
|
+
# Check children count
|
64
|
+
unless expectation[:children] == (actual = self.accounts.count)
|
65
|
+
raise IntegrityError.new("Number of accounts invalid: " \
|
66
|
+
+ "expected #{expectation[:children]}, actually: #{actual}")
|
67
|
+
end
|
68
|
+
|
69
|
+
# Check sum vs. account sums
|
70
|
+
actual_sum = self.accounts.map do |acct|
|
71
|
+
acct.instance_variable_get(:@trailer)[:account_control_total]
|
72
|
+
end.reduce(0, &:+)
|
73
|
+
|
74
|
+
unless expectation[:sum] == actual_sum
|
75
|
+
raise IntegrityError.new(
|
76
|
+
"Sums invalid: file: #{expectation[:sum]}, groups: #{actual_sum}")
|
77
|
+
end
|
78
|
+
|
79
|
+
# Run children assertions, which return number of records. May raise.
|
80
|
+
records = self.accounts.map {|a| a.send(:assert_integrity!) }.reduce(0, &:+)
|
81
|
+
|
82
|
+
unless expectation[:records] == (actual = records + 2)
|
83
|
+
raise IntegrityError.new(
|
84
|
+
"Record count invalid: group: #{expectation[:records]}, accounts: #{actual}")
|
85
|
+
end
|
86
|
+
|
87
|
+
# Return record count
|
88
|
+
records + 2
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
class Account
|
94
|
+
private
|
95
|
+
|
96
|
+
def assert_integrity!
|
97
|
+
expectation = {
|
98
|
+
sum: @trailer[:account_control_total],
|
99
|
+
records: @trailer[:number_of_records],
|
100
|
+
}
|
101
|
+
|
102
|
+
# Check sum vs. summary + transaction sums
|
103
|
+
actual_sum = self.transactions.map(&:amount).reduce(0, &:+) \
|
104
|
+
#+ self.summaries.map {|s| s[:amount] }.reduce(0, &:+)
|
105
|
+
# TODO: ^ there seems to be a disconnect between what the spec defines
|
106
|
+
# as the formula for the checksum and what SVB implements...
|
107
|
+
|
108
|
+
unless expectation[:sum] == actual_sum
|
109
|
+
raise IntegrityError.new(
|
110
|
+
"Sums invalid: expected: #{expectation[:sum]}, actual: #{actual_sum}")
|
111
|
+
end
|
112
|
+
|
113
|
+
# Run children assertions, which return number of records. May raise.
|
114
|
+
records = self.transactions.map do |tx|
|
115
|
+
tx.instance_variable_get(:@record).physical_record_count
|
116
|
+
end.reduce(0, &:+)
|
117
|
+
|
118
|
+
unless expectation[:records] == (actual = records + 2)
|
119
|
+
raise IntegrityError.new("Record count invalid: " \
|
120
|
+
+ "account: #{expectation[:records]}, transactions: #{actual}")
|
121
|
+
end
|
122
|
+
|
123
|
+
# Return record count
|
124
|
+
records + 2
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|