ach_builder 0.0.2 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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