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.
@@ -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