ach_builder 0.0.2 → 0.2.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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +12 -0
  3. data/.rspec +2 -1
  4. data/.ruby-gemset +1 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +10 -0
  7. data/Gemfile +11 -2
  8. data/MIT-LICENSE +1 -1
  9. data/README.md +19 -11
  10. data/Rakefile +44 -0
  11. data/ach_builder.gemspec +9 -5
  12. data/lib/ach/batch.rb +10 -32
  13. data/lib/ach/batch/builder.rb +60 -0
  14. data/lib/ach/batch/control.rb +18 -3
  15. data/lib/ach/batch/header.rb +21 -4
  16. data/lib/ach/component.rb +125 -43
  17. data/lib/ach/component/has_many_association.rb +155 -0
  18. data/lib/ach/constants.rb +15 -1
  19. data/lib/ach/file.rb +50 -44
  20. data/lib/ach/file/builder.rb +81 -0
  21. data/lib/ach/file/control.rb +15 -3
  22. data/lib/ach/file/header.rb +20 -3
  23. data/lib/ach/file/reader.rb +103 -0
  24. data/lib/ach/file/transmission_header.rb +77 -0
  25. data/lib/ach/formatter.rb +113 -76
  26. data/lib/ach/formatter/rule.rb +27 -0
  27. data/lib/ach/record.rb +13 -64
  28. data/lib/ach/record/addenda.rb +25 -0
  29. data/lib/ach/record/base.rb +109 -0
  30. data/lib/ach/record/dynamic.rb +58 -0
  31. data/lib/ach/record/entry.rb +49 -0
  32. data/lib/ach/record/tail.rb +10 -0
  33. data/lib/ach/validations.rb +5 -3
  34. data/lib/ach/version.rb +1 -1
  35. data/lib/ach_builder.rb +20 -33
  36. data/spec/batch_spec.rb +23 -11
  37. data/spec/componenet/has_many_association_spec.rb +111 -0
  38. data/spec/file_spec.rb +173 -81
  39. data/spec/formatter_spec.rb +9 -9
  40. data/spec/reader_spec.rb +55 -0
  41. data/spec/record/addenda_spec.rb +8 -0
  42. data/spec/record/base_spec.rb +53 -0
  43. data/spec/record/dynamic_spec.rb +74 -0
  44. data/spec/record/entry_spec.rb +7 -0
  45. data/spec/record/tail_spec.rb +7 -0
  46. data/spec/spec_helper.rb +1 -0
  47. data/spec/support/ach_file_factory.rb +70 -0
  48. data/spec/support/examples/well_fargo_empty.ach +2 -0
  49. data/spec/support/examples/well_fargo_with_data.ach +6 -0
  50. data/spec/support/helpers/ach_files_examples.rb +17 -0
  51. metadata +69 -26
  52. data/lib/ach/entry.rb +0 -31
  53. data/lib/ach/tail.rb +0 -6
  54. data/spec/entry_spec.rb +0 -7
  55. data/spec/record_spec.rb +0 -34
  56. data/spec/tail_spec.rb +0 -7
@@ -0,0 +1,155 @@
1
+ module ACH
2
+ # Objects of this class host essential functionality required to create
3
+ # associated object from within owner objects.
4
+ #
5
+ # Newly instantiated +HasManyAssociation+ object has no owner, and should
6
+ # be used to assign it's copies to owners via +for+ method. This technique
7
+ # has following application:
8
+ # class Batch < ACH::Component
9
+ # association = HasManyAssociation.new(:entries)
10
+ #
11
+ # association.delegation_methods.each do |method_name|
12
+ # delegate method_name, :to => '@batches_association'
13
+ # end
14
+ #
15
+ # after_initialize_hooks << lambda{ instance_variable_set('@batches_association', association.for(self)) }
16
+ # # All these lines of code are macrosed by <tt>ACH::Component.has_many</tt> method
17
+ # end
18
+ # # Now, whenever new batch is created, it will have it's own @batches_association,
19
+ # # and essential methods +batches+, +batch+, +build_batch+ delegated to it
20
+ # # (accordingly, to +container+, +create+, and +build+ methods)
21
+ class Component::HasManyAssociation
22
+ # If Record should be attached to (preceded by) other Record, this
23
+ # exception is raised on attempt to create attachment record without
24
+ # having preceded record. For example, Addenda records should be
25
+ # created after Entry records. Each new Addenda record will be attached
26
+ # to the latest Entry record.
27
+ class NoLinkError < ArgumentError
28
+ def initialize(link, klass)
29
+ super "No #{link} was found to attach a new #{klass}"
30
+ end
31
+ end
32
+
33
+ # Exception thrown if an association object, assigned for particular
34
+ # owner object, is used to assign to another owner object
35
+ class DoubleAssignmentError < StandardError
36
+ def initialize(name, owner)
37
+ super "Association #{name} has alredy been assigned to #{owner}"
38
+ end
39
+ end
40
+
41
+ attr_reader :name, :linked_to, :proc_defaults
42
+ private :linked_to, :proc_defaults
43
+
44
+ def initialize(plural_name, options = {})
45
+ @name = plural_name.to_s
46
+ @linked_to, @proc_defaults = options.values_at(:linked_to, :proc_defaults)
47
+ end
48
+
49
+ # Clones +self+ and assigns +owner+ to clone. Also, for newly created
50
+ # clone association that has owner, aliases main methods so that +owner+
51
+ # may delegate to them.
52
+ def for(owner)
53
+ raise DoubleAssignmentError.new(@name, @owner) if @owner
54
+
55
+ clone.tap do |association|
56
+ plural, singular = name, singular_name
57
+ association.instance_variable_set('@owner', owner)
58
+ association.singleton_class.class_eval do
59
+ alias_method "build_#{singular}", :build
60
+ alias_method singular, :create
61
+ alias_method plural, :container
62
+ end
63
+ end
64
+ end
65
+
66
+ # Returns an array of methods to be delegated by +owner+ of the association.
67
+ # For example, for association named :items, it will include:
68
+ # * +build_item+ - for instantiating Item from the string (used by parsing functionality)
69
+ # * +item+ - for instantiating Item during common ACH File creation
70
+ # * +items+ - that returns set of Item objects
71
+ def delegation_methods
72
+ ["build_#{singular_name}", singular_name, name]
73
+ end
74
+
75
+ # Uses <tt>klass#from_s</tt> to instantiate object from a string. Thus, +klass+ should be
76
+ # descendant of ACH::Record::Base. Then pushes object to appropriate container.
77
+ def build(str)
78
+ obj = klass.from_s(str)
79
+ container_for_associated << obj
80
+ end
81
+
82
+ # Creates associated object using common to ACH controls pattern, and pushes it to
83
+ # appropriate container. For example, for :items association, this method is
84
+ # aliased to +item+, so you will have:
85
+ # item(:code => 'WEB') do
86
+ # other_code 'BEW'
87
+ # # ...
88
+ # end
89
+ def create(*args, &block)
90
+ fields = args.first || {}
91
+
92
+ defaults = proc_defaults ? @owner.instance_exec(&proc_defaults) : {}
93
+
94
+ klass.new(@owner.fields_for(klass).merge(defaults).merge(fields)).tap do |component|
95
+ component.instance_eval(&block) if block
96
+ container_for_associated << component
97
+ end
98
+ end
99
+
100
+ # Returns main container for association. For plain (without :linked_to option), it is
101
+ # array. For linked associations, it is a hash, which keys are records from linking
102
+ # associations, and values are arrays for association's objects
103
+ def container
104
+ @container ||= linked? ? {} : []
105
+ end
106
+
107
+ # Returns array for associated object to be pushed in. For plain associations, it is
108
+ # equivalent to +container+. For linked associations, uses +@owner+ and linking
109
+ # association's name to get the latest record from linking associations. If it does
110
+ # not exist, +NoLinkError+ will be raised.
111
+ #
112
+ # Example:
113
+ # class Batch < ACH::Component
114
+ # has_many :entries
115
+ # has_many :addendas, :linked_to => :entries
116
+ # end
117
+ # batch = Batch.new
118
+ # batch.entry(:amount => 100)
119
+ # batch.addenda(:text => 'Foo')
120
+ # batch.entry(:amount => 200)
121
+ # batch.addenda(:text => 'Bar')
122
+ # batch.addenda(:text => 'Baz')
123
+ #
124
+ # batch.entries # => [<Entry, amount=100>, <Entry, amount=200>]
125
+ # batch.addendas # => {<Entry, amount=100> => [<Addenda, text='Foo'>],
126
+ # # <Entry, amount=200> => [<Addenda, text='Bar'>, <Addenda, text='Baz'>]}
127
+ def container_for_associated
128
+ return container unless linked?
129
+
130
+ last_link = @owner.send(linked_to).last
131
+ raise NoLinkError.new(linked_to.to_s.singularize, klass.name) unless last_link
132
+ container[last_link] ||= []
133
+ end
134
+
135
+ # Returns +true+ if association is linked to another association (thus, it's records must
136
+ # be preceded by other association's records). Returns +false+ otherwise
137
+ def linked?
138
+ !!linked_to
139
+ end
140
+ private :linked?
141
+
142
+ # Returns +klass+ that corresponds to association name. Should be defined either in
143
+ # ACH module, or in ACH::Record module
144
+ def klass
145
+ @klass ||= ACH.to_const(@name.classify.to_sym)
146
+ end
147
+ private :klass
148
+
149
+ # Returns singular name of the association
150
+ def singular_name
151
+ @singular_name ||= name.singularize
152
+ end
153
+ private :singular_name
154
+ end
155
+ end
@@ -1,7 +1,21 @@
1
1
  module ACH
2
2
  module Constants
3
+ # The length of each record in characters.
3
4
  RECORD_SIZE = 94
5
+ # The file's total record count must be a multiple of this number. The
6
+ # file must be padded with blocking file control records (consisting
7
+ # entirely of 9s) to satisfy this condition.
4
8
  BLOCKING_FACTOR = 10
9
+ # Always "1".
5
10
  FORMAT_CODE = 1
6
- end
11
+ # This character must be used to delimit each row.
12
+ ROWS_DELIMITER = "\n"
13
+
14
+ FILE_HEADER_RECORD_TYPE = 1
15
+ FILE_CONTROL_RECORD_TYPE = 9
16
+ BATCH_HEADER_RECORD_TYPE = 5
17
+ BATCH_ENTRY_RECORD_TYPE = 6
18
+ BATCH_ADDENDA_RECORD_TYPE = 7
19
+ BATCH_CONTROL_RECORD_TYPE = 8
20
+ end
7
21
  end
@@ -1,49 +1,55 @@
1
1
  module ACH
2
+ # An ACH::File instance represents an actual ACH file. Every file has an
3
+ # ACH::File::Header and ACH::File::Control records and a variable number of
4
+ # ACH::Batches. The ACH::File::TransmissionHeader is optional. (Refer to the
5
+ # target financial institution's documentation.)
6
+ #
7
+ # == Example
8
+ #
9
+ # # Subclass ACH::File to set default values:
10
+ # class CustomAchFile < ACH::File
11
+ # immediate_dest '123123123'
12
+ # immediate_dest_name 'COMMERCE BANK'
13
+ # immediate_origin '123123123'
14
+ # immediate_origin_name 'MYCOMPANY'
15
+ # end
16
+ #
17
+ # # Create a new instance:
18
+ # ach_file = CustomAchFile.new do
19
+ # batch(:entry_class_code => "WEB", :company_entry_descr => "TV-TELCOM") do
20
+ # effective_date Time.now.strftime('%y%m%d')
21
+ # desc_date Time.now.strftime('%b %d').upcase
22
+ # origin_dfi_id "00000000"
23
+ # entry :customer_name => 'JOHN SMITH',
24
+ # :customer_acct => '61242882282',
25
+ # :amount => '2501',
26
+ # :routing_number => '010010101',
27
+ # :bank_account => '103030030'
28
+ # end
29
+ # end
30
+ #
31
+ # # convert to string
32
+ # ach_file.to_s! # => returns string representation of file
33
+ #
34
+ # # write to file
35
+ # ach_file.write('custom_ach.txt')
2
36
  class File < Component
3
- has_many :batches, lambda{ {:batch_number => batches.length + 1} }
4
-
5
- def batch_count
6
- batches.length
7
- end
8
-
9
- def block_count
10
- (file_entry_count.to_f / BLOCKING_FACTOR).ceil
11
- end
12
-
13
- def file_entry_count
14
- batches.map{ |b| b.entries.length }.inject(&:+) || 0
15
- end
16
-
17
- def entry_hash
18
- batches.map(&:entry_hash).compact.inject(&:+)
19
- end
20
-
21
- def total_debit_amount
22
- batches.map(&:total_debit_amount).compact.inject(&:+)
23
- end
24
-
25
- def total_credit_amount
26
- batches.map(&:total_credit_amount).compact.inject(&:+)
27
- end
28
-
29
- def to_ach
30
- extra = block_count * BLOCKING_FACTOR - file_entry_count
31
- tail = ([Tail.new] * extra).unshift(control)
32
- [header] + batches.map(&:to_ach).flatten + tail
33
- end
34
-
35
- def to_s!
36
- to_ach.map(&:to_s!).join("\r\n") + "\r\n"
37
- end
38
-
39
- def record_count
40
- 2 + batches.length * 2 + file_entry_count
41
- end
42
-
43
- def write filename
44
- return false unless valid?
45
- ::File.open(filename, 'w') do |fh|
46
- fh.write(to_s!)
37
+ autoload :Builder
38
+ autoload :Control
39
+ autoload :Header
40
+ autoload :TransmissionHeader
41
+ autoload :Reader
42
+
43
+ include Builder
44
+ include TransmissionHeader
45
+
46
+ has_many :batches, :proc_defaults => lambda{ {:batch_number => batches.length + 1} }
47
+
48
+ # Opens a +filename+ and passes it's handler to the ACH::Reader object, which uses it as
49
+ # enum to scan for ACH contents line by line.
50
+ def self.read(filename)
51
+ ::File.open(filename) do |fh|
52
+ Reader.new(fh).to_ach
47
53
  end
48
54
  end
49
55
  end
@@ -0,0 +1,81 @@
1
+ module ACH
2
+ # This module hosts all the methods required for building string representation of an ACH file,
3
+ # and writing it to an actual file in the filesystem. Included by the ACH::File.
4
+ module File::Builder
5
+ # Returns amount of +batches+ in file
6
+ def batch_count
7
+ batches.length
8
+ end
9
+
10
+ # Returns amount of blocks, used in count. This amount is based on <tt>blocking factor</tt>,
11
+ # which is usually equals to 10, and on overall amount of records in a file. Return value
12
+ # represents the least amount of blocks taken by records in file.
13
+ def block_count
14
+ (record_count.to_f / Constants::BLOCKING_FACTOR).ceil
15
+ end
16
+
17
+ # Returns total amount of +entry+ and +addenda+ records of all batches within file.
18
+ def file_entry_addenda_count
19
+ batches.map{ |batch| batch.entry_addenda_count }.inject(&:+) || 0
20
+ end
21
+
22
+ # Returns sum of +entry_hash+ values of all batches within self
23
+ def entry_hash
24
+ batch_sum_of(:entry_hash)
25
+ end
26
+
27
+ # Returns sum of +total_debit_amount+ values of all batches within self
28
+ def total_debit_amount
29
+ batch_sum_of(:total_debit_amount)
30
+ end
31
+
32
+ # Returns sum of +total_credit_amount+ values of all batches within self
33
+ def total_credit_amount
34
+ batch_sum_of(:total_credit_amount)
35
+ end
36
+
37
+ # Returns complete string representation of a ACH file by converting each interval record
38
+ # to a string and joining the result by <tt>Constants::ROWS_DELIMITER</tt>
39
+ def to_s!
40
+ to_ach.map(&:to_s!).join(Constants::ROWS_DELIMITER)
41
+ end
42
+
43
+ # Returns total amount of records hosted by a file.
44
+ def record_count
45
+ 2 + batch_count * 2 + file_entry_addenda_count
46
+ end
47
+
48
+ # Writes string representation of self to passed +filename+
49
+ def write(filename)
50
+ return false unless valid?
51
+ ::File.open(filename, 'w') do |fh|
52
+ fh.write(to_s!)
53
+ end
54
+ end
55
+
56
+ # Helper method for calculating different properties of batches within file
57
+ def batch_sum_of(meth)
58
+ batches.map(&meth).compact.inject(&:+)
59
+ end
60
+ private :batch_sum_of
61
+
62
+ # Returns well-fetched array of all ACH records in the file, appending proper
63
+ # amount, based on number of blocks, of tail records to it.
64
+ def to_ach
65
+ head = [ header ]
66
+ head.unshift(transmission_header) if have_transmission_header?
67
+ head + batches.map(&:to_ach).flatten + [control] + tail
68
+ end
69
+
70
+ # Returns array of ACH::Record::Tail records, based on +tails_count+
71
+ def tail
72
+ [ Record::Tail.new ] * tails_count
73
+ end
74
+
75
+ # Returns amount of ACH::Record::Tail records, required to append to
76
+ # string representation of a file to match proper amount of blocks.
77
+ def tails_count
78
+ block_count * Constants::BLOCKING_FACTOR - record_count
79
+ end
80
+ end
81
+ end
@@ -1,15 +1,27 @@
1
1
  module ACH
2
- class File::Control < Record
2
+ # Every ACH::File ends with an ACH::File::Control record.
3
+ #
4
+ # == Fields:
5
+ #
6
+ # * record_type
7
+ # * batch_count
8
+ # * block_count
9
+ # * file_entry_addenda_count
10
+ # * entry_hash
11
+ # * total_debit_amount
12
+ # * total_credit_amount
13
+ # * bank_39
14
+ class File::Control < Record::Base
3
15
  fields :record_type,
4
16
  :batch_count,
5
17
  :block_count,
6
- :file_entry_count,
18
+ :file_entry_addenda_count,
7
19
  :entry_hash,
8
20
  :total_debit_amount,
9
21
  :total_credit_amount,
10
22
  :bank_39
11
23
 
12
- defaults :record_type => 9,
24
+ defaults :record_type => FILE_CONTROL_RECORD_TYPE,
13
25
  :bank_39 => ''
14
26
  end
15
27
  end
@@ -1,5 +1,23 @@
1
1
  module ACH
2
- class File::Header < Record
2
+ # An ACH::File::Header record is the first record of every ACH::File
3
+ # (in case the ACH::File::TransmissionHeader record is absent).
4
+ #
5
+ # == Fields:
6
+ #
7
+ # * record_type
8
+ # * priority_code
9
+ # * immediate_dest
10
+ # * immediate_origin
11
+ # * date
12
+ # * time
13
+ # * file_id_modifier
14
+ # * record_size
15
+ # * blocking_factor
16
+ # * format_code
17
+ # * immediate_dest_name
18
+ # * immediate_origin_name
19
+ # * reference_code
20
+ class File::Header < Record::Base
3
21
  fields :record_type,
4
22
  :priority_code,
5
23
  :immediate_dest,
@@ -14,9 +32,8 @@ module ACH
14
32
  :immediate_origin_name,
15
33
  :reference_code
16
34
 
17
- defaults :record_type => 1,
35
+ defaults :record_type => FILE_HEADER_RECORD_TYPE,
18
36
  :priority_code => 1,
19
- :reference_code => '',
20
37
  :date => lambda{ Time.now.strftime("%y%m%d") },
21
38
  :time => lambda{ Time.now.strftime("%H%M") },
22
39
  :file_id_modifier => 'A',
@@ -0,0 +1,103 @@
1
+ module ACH
2
+ # The +ACH::File::Reader+ class builds a corresponding +ACH::File+
3
+ # object from a given set of data. The constructor takes an +enum+ object
4
+ # representing a sequence of ACH lines (strings). This +enum+ object may be
5
+ # a file handler, an array, or any other object that responds to the +#each+
6
+ # method.
7
+ class File::Reader
8
+ include Constants
9
+
10
+ def initialize(enum)
11
+ @enum = enum
12
+ end
13
+
14
+ def to_ach
15
+ header_line, batch_lines, control_line = ach_data
16
+
17
+ File.new do
18
+ build_header header_line
19
+
20
+ batch_lines.each do |batch_data|
21
+ batch do
22
+ build_header batch_data[:header]
23
+
24
+ batch_data[:entries].each do |entry_line|
25
+ build_entry entry_line
26
+
27
+ if batch_data[:addendas].key?(entry_line)
28
+ batch_data[:addendas][entry_line].each do |addenda_line|
29
+ build_addenda addenda_line
30
+ end
31
+ end
32
+ end
33
+
34
+ build_control batch_data[:control]
35
+ end
36
+ end
37
+
38
+ build_control control_line
39
+ end
40
+ end
41
+
42
+ def ach_data
43
+ process! unless processed?
44
+
45
+ return @header, batches, @control
46
+ end
47
+ private :ach_data
48
+
49
+ def process!
50
+ each_line do |record_type, line|
51
+ case record_type
52
+ when FILE_HEADER_RECORD_TYPE
53
+ @header = line
54
+ when BATCH_HEADER_RECORD_TYPE
55
+ initialize_batch!
56
+ current_batch[:header] = line
57
+ when BATCH_ENTRY_RECORD_TYPE
58
+ current_batch[:entries] << line
59
+ when BATCH_ADDENDA_RECORD_TYPE
60
+ (current_batch[:addendas][current_entry] ||= []) << line
61
+ when BATCH_CONTROL_RECORD_TYPE
62
+ current_batch[:control] = line
63
+ when FILE_CONTROL_RECORD_TYPE
64
+ @control = line
65
+ end
66
+ end
67
+ @processed = true
68
+ end
69
+ private :process!
70
+
71
+ def processed?
72
+ !!@processed
73
+ end
74
+ private :processed?
75
+
76
+ def each_line
77
+ @enum.each do |line|
78
+ yield line[0..0].to_i, line.chomp
79
+ end
80
+ end
81
+ private :each_line
82
+
83
+ def batches
84
+ @batches ||= []
85
+ end
86
+ private :batches
87
+
88
+ def initialize_batch!
89
+ batches << {:entries => [], :addendas => {}}
90
+ end
91
+ private :initialize_batch!
92
+
93
+ def current_batch
94
+ batches.last
95
+ end
96
+ private :current_batch
97
+
98
+ def current_entry
99
+ current_batch[:entries].last
100
+ end
101
+ private :current_entry
102
+ end
103
+ end