bai2 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ *.bundle
11
+ *.so
12
+ *.o
13
+ *.a
14
+ mkmf.log
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in bai2.gemspec
4
+ gemspec
@@ -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.
@@ -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
@@ -0,0 +1,7 @@
1
+ require 'bundler/gem_tasks'
2
+
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.pattern ='test/tests/*.rb'
7
+ end
@@ -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
@@ -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