ach_client 0.5.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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +18 -0
  3. data/.gitignore +9 -0
  4. data/.rubocop.yml +1156 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +10 -0
  7. data/Gemfile +4 -0
  8. data/README.md +388 -0
  9. data/Rakefile +10 -0
  10. data/ach_client.gemspec +58 -0
  11. data/bin/console +93 -0
  12. data/bin/setup +8 -0
  13. data/config/return_codes.yml +761 -0
  14. data/lib/ach_client/abstract/abstract_method_error.rb +3 -0
  15. data/lib/ach_client/helpers/dollars_to_cents.rb +15 -0
  16. data/lib/ach_client/logging/log_provider_job.rb +36 -0
  17. data/lib/ach_client/logging/log_providers/log_provider.rb +22 -0
  18. data/lib/ach_client/logging/log_providers/null_log_provider.rb +14 -0
  19. data/lib/ach_client/logging/log_providers/stdout_log_provider.rb +14 -0
  20. data/lib/ach_client/logging/logging.rb +76 -0
  21. data/lib/ach_client/logging/savon_observer.rb +26 -0
  22. data/lib/ach_client/objects/account_types.rb +32 -0
  23. data/lib/ach_client/objects/responses/ach_response.rb +15 -0
  24. data/lib/ach_client/objects/responses/corrected_ach_response.rb +18 -0
  25. data/lib/ach_client/objects/responses/processing_ach_response.rb +6 -0
  26. data/lib/ach_client/objects/responses/returned_ach_response.rb +16 -0
  27. data/lib/ach_client/objects/responses/settled_ach_response.rb +5 -0
  28. data/lib/ach_client/objects/return_code.rb +20 -0
  29. data/lib/ach_client/objects/return_codes.rb +33 -0
  30. data/lib/ach_client/objects/transaction_types.rb +16 -0
  31. data/lib/ach_client/providers/abstract/ach_batch.rb +23 -0
  32. data/lib/ach_client/providers/abstract/ach_status_checker.rb +21 -0
  33. data/lib/ach_client/providers/abstract/ach_transaction.rb +70 -0
  34. data/lib/ach_client/providers/abstract/company_info.rb +28 -0
  35. data/lib/ach_client/providers/abstract/response_record_processor.rb +15 -0
  36. data/lib/ach_client/providers/abstract/transformer.rb +38 -0
  37. data/lib/ach_client/providers/sftp/account_type_transformer.rb +20 -0
  38. data/lib/ach_client/providers/sftp/ach_batch.rb +97 -0
  39. data/lib/ach_client/providers/sftp/ach_status_checker.rb +146 -0
  40. data/lib/ach_client/providers/sftp/ach_transaction.rb +62 -0
  41. data/lib/ach_client/providers/sftp/nacha_provider.rb +30 -0
  42. data/lib/ach_client/providers/sftp/sftp_provider.rb +124 -0
  43. data/lib/ach_client/providers/sftp/transaction_type_transformer.rb +19 -0
  44. data/lib/ach_client/providers/soap/ach_works/account_type_transformer.rb +17 -0
  45. data/lib/ach_client/providers/soap/ach_works/ach_batch.rb +89 -0
  46. data/lib/ach_client/providers/soap/ach_works/ach_status_checker.rb +86 -0
  47. data/lib/ach_client/providers/soap/ach_works/ach_transaction.rb +80 -0
  48. data/lib/ach_client/providers/soap/ach_works/ach_works.rb +57 -0
  49. data/lib/ach_client/providers/soap/ach_works/company_info.rb +87 -0
  50. data/lib/ach_client/providers/soap/ach_works/correction_details_processor.rb +92 -0
  51. data/lib/ach_client/providers/soap/ach_works/date_formatter.rb +33 -0
  52. data/lib/ach_client/providers/soap/ach_works/response_record_processor.rb +91 -0
  53. data/lib/ach_client/providers/soap/ach_works/transaction_type_transformer.rb +18 -0
  54. data/lib/ach_client/providers/soap/i_check_gateway/account_type_transformer.rb +20 -0
  55. data/lib/ach_client/providers/soap/i_check_gateway/ach_batch.rb +12 -0
  56. data/lib/ach_client/providers/soap/i_check_gateway/ach_status_checker.rb +35 -0
  57. data/lib/ach_client/providers/soap/i_check_gateway/ach_transaction.rb +53 -0
  58. data/lib/ach_client/providers/soap/i_check_gateway/company_info.rb +51 -0
  59. data/lib/ach_client/providers/soap/i_check_gateway/i_check_gateway.rb +39 -0
  60. data/lib/ach_client/providers/soap/i_check_gateway/response_record_processor.rb +59 -0
  61. data/lib/ach_client/providers/soap/i_check_gateway/transaction_type_transformer.rb +8 -0
  62. data/lib/ach_client/providers/soap/soap_provider.rb +47 -0
  63. data/lib/ach_client/version.rb +4 -0
  64. data/lib/ach_client.rb +38 -0
  65. metadata +346 -0
@@ -0,0 +1,97 @@
1
+ module AchClient
2
+ # Namespace for all things Sftp
3
+ class Sftp
4
+ # NACHA representation of an AchBatch
5
+ class AchBatch < Abstract::AchBatch
6
+
7
+ def initialize(ach_transactions: [], batch_number: nil)
8
+ super(ach_transactions: ach_transactions)
9
+ @batch_number = batch_number
10
+ end
11
+
12
+ # The filename used for the batch
13
+ # @return [String] filename to use
14
+ def batch_file_name
15
+ self.class.parent.file_naming_strategy.(@batch_number)
16
+ end
17
+
18
+ # Sends the batch to SFTP provider
19
+ # @return [Array<String>]
20
+ def send_batch
21
+ self.class.parent.write_remote_file(
22
+ file_path: File.join(
23
+ self.class.parent.outgoing_path,
24
+ batch_file_name
25
+ ),
26
+ file_body: cook_some_nachas.to_s
27
+ )
28
+ @ach_transactions.map(&:external_ach_id)
29
+ end
30
+
31
+ # Converts this AchBatch into the NACHA object representation provided
32
+ # by the ACH gem.
33
+ # @return [ACH::ACHFile] Yo NACHA
34
+ def cook_some_nachas
35
+ nacha = ACH::ACHFile.new
36
+ nacha.instance_variable_set(:@header, nacha_file_header)
37
+
38
+ # The NACHA can have multiple batches.
39
+ # Transactions in the same batch must have the same originator and
40
+ # sec_code, so we group by sec_code and originator when building batches
41
+ @ach_transactions.group_by(&:sec_code).map do |sec_code, transactions|
42
+ transactions.group_by(&:originator_name)
43
+ .map do |originator_name, batch_transactions|
44
+ batch_transactions.group_by(&:effective_entry_date)
45
+ .map do |effective_entry_date, batched_transactions|
46
+ nacha.batches << nacha_batch(
47
+ sec_code,
48
+ originator_name,
49
+ effective_entry_date,
50
+ batched_transactions
51
+ )
52
+ end
53
+ end
54
+ end
55
+ nacha
56
+ end
57
+
58
+ private
59
+ def nacha_file_header
60
+ file_header = ACH::Records::FileHeader.new
61
+ [
62
+ :immediate_destination,
63
+ :immediate_destination_name,
64
+ :immediate_origin,
65
+ :immediate_origin_name
66
+ ].each do |attribute|
67
+ file_header.send("#{attribute}=", self.class.parent.send(attribute))
68
+ end
69
+ file_header
70
+ end
71
+
72
+ def nacha_batch(
73
+ sec_code,
74
+ originator_name,
75
+ effective_entry_date,
76
+ transactions
77
+ )
78
+ batch = ACH::Batch.new
79
+ batch_header = batch.header
80
+ batch_header.company_name = originator_name
81
+ batch_header.company_identification =
82
+ self.class.parent.company_identification
83
+ batch_header.standard_entry_class_code = sec_code
84
+ batch_header.company_entry_description =
85
+ self.class.parent.company_entry_description
86
+ batch_header.company_descriptive_date = effective_entry_date
87
+ batch_header.effective_entry_date = effective_entry_date
88
+ batch_header.originating_dfi_identification =
89
+ self.class.parent.originating_dfi_identification
90
+ transactions.each do |transaction|
91
+ batch.entries << transaction.to_entry_detail
92
+ end
93
+ batch
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,146 @@
1
+ module AchClient
2
+ class Sftp
3
+ # Poll SFTP provider for Inbox files
4
+ class AchStatusChecker < Abstract::AchStatusChecker
5
+
6
+ ##
7
+ # Gets the status of ach transactions since the last time we ran this
8
+ # method
9
+ # @return [Hash{String => AchClient::AchResponse}] Hash with
10
+ # individual_id_number values as keys, AchResponse objects as values
11
+ def self.most_recent
12
+ process_files(most_recent_files)
13
+ end
14
+
15
+ ##
16
+ # Gets the status of ach transactions between the given dates
17
+ # @param start_date [String] lower bound of date ranged status query
18
+ # @param end_date [String] upper bound of date ranged status query
19
+ # @return [Hash{String => AchClient::AchResponse}] Hash with
20
+ # individual_id_number values as keys, AchResponse objects as values
21
+ def self.in_range(start_date:, end_date:)
22
+ in_range = {}
23
+ self.parent.with_sftp_connection do |connection|
24
+ in_range = process_files(
25
+ files_in_range(
26
+ connection: connection,
27
+ start_date: start_date,
28
+ end_date: end_date
29
+ )
30
+ )
31
+ end
32
+ in_range
33
+ end
34
+
35
+ private_class_method def self.process_files(files)
36
+ files.reduce({}) do |acc, entry|
37
+ ACH::ACHFile.new(entry.last).batches.map do |batch|
38
+ batch.entries.map do |ach|
39
+ # return trace ==> response
40
+ { ach.individual_id_number => process_ach(batch, ach) }
41
+ end.reduce({}, &:merge)
42
+ end.reduce({}, &:merge).merge(acc)
43
+ end
44
+ end
45
+
46
+ private_class_method def self.process_ach(batch, ach)
47
+ if ach.addenda.length == 0
48
+ # If there are no addenda, it is a success.
49
+ AchClient::SettledAchResponse.new(
50
+ amount: (ach.amount / 100.0).to_d,
51
+ date: batch.header.effective_entry_date
52
+ )
53
+ else
54
+ # If there is an addenda, it is a return
55
+ process_ach_return(batch, ach)
56
+ end
57
+ end
58
+
59
+ private_class_method def self.process_ach_return(batch, ach)
60
+ # I'm not sure why/if there would be multiple addenda.
61
+ # So just taking the first one.
62
+ case ach.addenda.first.reason_code.first
63
+ # If the first letter is R, it is a return
64
+ when 'R'
65
+ AchClient::ReturnedAchResponse.new(
66
+ amount: (ach.amount / 100.0).to_d,
67
+ date: batch.header.effective_entry_date,
68
+ return_code: AchClient::ReturnCodes.find_by(
69
+ code: ach.addenda.first.reason_code
70
+ )
71
+ )
72
+ # If the first letter is C, it is a correction
73
+ when 'C'
74
+ AchClient::CorrectedAchResponse.new(
75
+ amount: (ach.amount / 100.0).to_d,
76
+ date: batch.header.effective_entry_date,
77
+ return_code: AchClient::ReturnCodes.find_by(
78
+ code: ach.addenda.first.reason_code
79
+ ),
80
+ corrections: {
81
+ # The key for deciphering this field is probably documented
82
+ # somewhere. We can expand on this once there is a use case for
83
+ # automatically processing corrections
84
+ unhandled_correction_data: ach.addenda.first.corrected_data
85
+ }
86
+ )
87
+ end
88
+ end
89
+
90
+ private_class_method def self.inbox_path_to(filename)
91
+ "#{self.parent.incoming_path}/#{filename}"
92
+ end
93
+
94
+ private_class_method def self.files_in_range(
95
+ connection:,
96
+ start_date:,
97
+ end_date: nil
98
+ )
99
+ # Get info on all files - equivalent to `ls`
100
+ connection.dir.entries(self.parent.incoming_path)
101
+ .select do |file|
102
+ last_modified_time = Time.at(file.attributes.mtime)
103
+ # Filter to files modified in date range
104
+ last_modified_time > start_date && (
105
+ !end_date || last_modified_time < end_date
106
+ )
107
+ end.map do |file|
108
+ body = connection.file.open(inbox_path_to(file.name), 'r').read
109
+ AchClient::Logging::LogProviderJob.perform_async(
110
+ body: body,
111
+ name: "response-#{DateTime.now}-#{file.name.parameterize}"
112
+ )
113
+ { file.name => body }
114
+ end.reduce(&:merge)
115
+ end
116
+
117
+ # Returns the last datetime that the most_recent function was called
118
+ private_class_method def self.last_most_recent_check_date(connection:)
119
+ most_recent_string = connection.file.open(
120
+ inbox_path_to('most_recent'),
121
+ 'r'
122
+ ).read
123
+ DateTime.parse(most_recent_string)
124
+ end
125
+
126
+ # Leave file with last grabbed date
127
+ private_class_method def self.update_most_recent_check_date(connection:)
128
+ connection.file.open(inbox_path_to('most_recent'), 'w') do |file|
129
+ file.write(DateTime.now.to_s)
130
+ end
131
+ end
132
+
133
+ private_class_method def self.most_recent_files
134
+ files = []
135
+ self.parent.with_sftp_connection do |connection|
136
+ files = files_in_range(
137
+ connection: connection,
138
+ start_date: last_most_recent_check_date(connection: connection)
139
+ )
140
+ update_most_recent_check_date(connection: connection)
141
+ end
142
+ files
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,62 @@
1
+ module AchClient
2
+ class Sftp
3
+ # Generic SFTP provider representation of a single ACH Transaction
4
+ class AchTransaction < Abstract::AchTransaction
5
+
6
+ # Most SFTP providers only support batch transactions
7
+ def send
8
+ raise 'NACHA/SFTP providers do not support individual transactions'
9
+ end
10
+
11
+ # Converts this ach transaction to the ACH gem's representation of a
12
+ # ach transaction for eventual NACHA transformation
13
+ # @return [ACH::EntryDetail] ACH gem's ach transaction record
14
+ def to_entry_detail
15
+ entry = ACH::EntryDetail.new
16
+ entry.transaction_code = transaction_code
17
+ entry.routing_number = routing_number
18
+ entry.account_number = account_number
19
+ entry.amount = amount_in_cents
20
+ entry.individual_id_number = external_ach_id # Doesn't need to be a number
21
+ entry.individual_name = merchant_name
22
+ entry.originating_dfi_identification =
23
+ self.class.parent.originating_dfi_identification
24
+ entry.trace_number = external_ach_id.gsub(/\D/, '').to_i # Does need to be a number
25
+ entry
26
+ end
27
+
28
+ private
29
+
30
+ def amount_in_cents
31
+ # Take absolute value in case amount is negative
32
+ Helpers::DollarsToCents.dollars_to_cents(amount.abs)
33
+ end
34
+
35
+ def transaction_code
36
+ [
37
+ AccountTypeTransformer.serialize_to_provider_value(
38
+ account_type
39
+ ),
40
+ TransactionTypeTransformer.serialize_to_provider_value(
41
+ potentially_flipped_transaction_type
42
+ )
43
+ ].reduce(&:+)
44
+ end
45
+
46
+ # Some NACHA providers can't handle negative amounts
47
+ # So we flip the amount and toggle the transaction type between
48
+ # Debit and Credit or vice versa
49
+ def potentially_flipped_transaction_type
50
+ if amount.negative?
51
+ if transaction_type == TransactionTypes::Credit
52
+ TransactionTypes::Debit
53
+ else
54
+ TransactionTypes::Credit
55
+ end
56
+ else
57
+ self.transaction_type
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,30 @@
1
+ module AchClient
2
+ # Base concern for providers like SVB and BOA that use NACHA format
3
+ module NachaProvider
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ # @return [String] Immediate Destination ID provided by SVB, refers to SVB
8
+ class_attribute :immediate_destination
9
+
10
+ # @return [String] Immediate Destination Name provided by SVB, refers to SVB
11
+ class_attribute :immediate_destination_name
12
+
13
+ # @return [String] Immediate Origin ID provided by SVB, refers to you
14
+ class_attribute :immediate_origin
15
+
16
+ # @return [String] Immediate Origin Name provided by SVB, refers to you
17
+ class_attribute :immediate_origin_name
18
+
19
+ # @return [String] Company Identification provided by SVB, refers to you
20
+ class_attribute :company_identification
21
+
22
+ # @return [String] Company Entry Description, whatever that means
23
+ class_attribute :company_entry_description
24
+
25
+ # @return [String] originating_dfi_identification refers to your bank?
26
+ # originating_dfi => "Originating Depository Financial Institution"
27
+ class_attribute :originating_dfi_identification
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,124 @@
1
+ require 'net/sftp'
2
+
3
+ module AchClient
4
+ # Base concern for providers like SVB that use an SFTP system instead of API
5
+ module SftpProvider
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ # @return [String] Hostname/URL of SVB's "Direct File Transmission" server
10
+ class_attribute :host
11
+
12
+ # @return [String] The username they gave you to login to the server
13
+ class_attribute :username
14
+
15
+ # @return [String] The password they gave you to login to the server
16
+ class_attribute :password
17
+
18
+ # @return [String] The private ssh key that matches the public ssh key you
19
+ # provided to SVB, ie the output of `cat path/to/private/ssh/key`
20
+ class_attribute :private_ssh_key
21
+
22
+ # @return [String | NilClass] Passphrase for your private ssh key
23
+ # (if applicable)
24
+ class_attribute :passphrase
25
+
26
+ # @return [String] The path on the remote server to the directory where
27
+ # you will deposit your outgoing NACHA files
28
+ class_attribute :outgoing_path
29
+
30
+ # @return [String] The path on the remote server to the directory where
31
+ # the SFTP provider will deposit return/confirmation files
32
+ class_attribute :incoming_path
33
+
34
+ # @return [Proc] A function that defines the filenaming strategy for your
35
+ # provider. The function should take an optional batch number and return
36
+ # a filename string
37
+ class_attribute :file_naming_strategy
38
+
39
+ # Executes the given block with an obtained SFTP connection configured
40
+ # using the above settings
41
+ # @param block [Proc] the code to execute with a provided connection
42
+ # @return [Object] the result of the block
43
+ def self.with_sftp_connection(&_block)
44
+ Net::SFTP.start(*connection_params) do |sftp_connection|
45
+ yield(sftp_connection)
46
+ end
47
+ end
48
+
49
+ # Opens an SFTP connection, and writes a new file at the given path with
50
+ # the given body
51
+ # @param file_path [String] path to the new file on the remote server
52
+ # @param file_body [String] text you want to write to the new file
53
+ # @return [String] the filename written to, if successful
54
+ def self.write_remote_file(file_path:, file_body:)
55
+ self.with_sftp_connection do |sftp_connection|
56
+ sftp_connection.file.open(file_path, 'w') do |file|
57
+ # Log the file contents
58
+ AchClient::Logging::LogProviderJob.perform_async(
59
+ body: file_body,
60
+ name: "request-#{DateTime.now}-#{file_path.parameterize}"
61
+ )
62
+ # Write the file on the remote server
63
+ file.puts(file_body)
64
+ end
65
+ end
66
+ file_path
67
+ end
68
+
69
+ # Lists the files in the given directory that match the given glob
70
+ # @param file_path [String] path to directory the search will start from
71
+ # @param glob [String] the glob to search for, ie "*.rb" => all .rb files
72
+ # @return [Array<String>] List of discovered filenames that match the glob
73
+ def self.list_files(file_path:, glob:)
74
+ result = nil
75
+ self.with_sftp_connection do |sftp_connection|
76
+ result = sftp_connection.dir.glob(file_path, glob)
77
+ end
78
+ result.map(&:name)
79
+ end
80
+
81
+ # Returns the contents of the files in the given directory that match
82
+ # the given glob
83
+ # @param file_path [String] path to directory the search will start from
84
+ # @param glob [String] the glob to search for, ie "*.rb" => all .rb files
85
+ # @return [{String => String}] Hash of discovered files, where each
86
+ # key is a filename and each value is a file's contents
87
+ def self.retrieve_files(file_path:, glob:)
88
+ files = nil
89
+ # retrieve the files from the remote server
90
+ self.with_sftp_connection do |sftp_connection|
91
+ files = sftp_connection.dir.glob(file_path, glob).map do |file|
92
+ {
93
+ file.name =>
94
+ sftp_connection.file.open(
95
+ File.join(file_path, file.name),
96
+ 'r'
97
+ ).read
98
+ }
99
+ end.reduce(&:merge)
100
+ end
101
+ # log the retrieved files
102
+ files.each do |name, body|
103
+ AchClient::Logging::LogProviderJob.perform_async(
104
+ body: body,
105
+ name: "response-#{DateTime.now}-#{name}"
106
+ )
107
+ end
108
+ files
109
+ end
110
+
111
+ private_class_method def self.connection_params
112
+ [
113
+ host,
114
+ username,
115
+ [
116
+ (private_ssh_key ? {key_data: [private_ssh_key]} : nil),
117
+ (passphrase ? {passphrase: passphrase} : nil),
118
+ password: password
119
+ ].compact.reduce(&:merge)
120
+ ]
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,19 @@
1
+ module AchClient
2
+ class Sftp
3
+ ##
4
+ # Transforms TransactionTypes between AchClient class and the string
5
+ # that NACHA expects
6
+ class TransactionTypeTransformer < AchClient::Transformer
7
+ # '2' means Credit, '7' means Debit
8
+ # The account type string is the second character in the transaction_code
9
+ # field.
10
+ # @return [Hash {String => Class}] the mapping
11
+ def self.transformer
12
+ {
13
+ '2' => AchClient::TransactionTypes::Credit,
14
+ '7' => AchClient::TransactionTypes::Debit
15
+ }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ module AchClient
2
+ class AchWorks
3
+ ##
4
+ # Transforms AccountTypes between AchClient class and the string
5
+ # that AchWorks expects
6
+ class AccountTypeTransformer < AchClient::Transformer
7
+ # 'C' means Checking, 'S' means Savings
8
+ # @return [Hash {String => Class}] the mapping
9
+ def self.transformer
10
+ {
11
+ 'S' => AchClient::AccountTypes::Savings,
12
+ 'C' => AchClient::AccountTypes::Checking
13
+ }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,89 @@
1
+ module AchClient
2
+ class AchWorks
3
+
4
+ # AchWorks implementation for AchBatch
5
+ class AchBatch < Abstract::AchBatch
6
+
7
+ ##
8
+ # Sends the batch to AchWorks using their SendACHTransBatch SOAP action
9
+ # If the action is successful, will return the filename that AchWorks
10
+ # gives us. We can use that to track the batch processing later on.
11
+ # If it fails, an exception will be thrown.
12
+ # @return [Array<String>] the external_ach_id's for each transaction
13
+ def send_batch
14
+ AchClient::AchWorks.wrap_request(
15
+ method: :send_ach_trans_batch,
16
+ message: self.to_hash,
17
+ path: [:send_ach_trans_batch_response, :send_ach_trans_batch_result]
18
+ )[:file_name]
19
+ external_ach_ids
20
+ end
21
+
22
+ # Converts this batch to a hash which can be sent to ACHWorks via Savon
23
+ # @return [Hash] hash to send to AchWorks
24
+ def to_hash
25
+ CompanyInfo.build.to_hash.merge(
26
+ {
27
+ InpACHFile:{
28
+ SSS: AchClient::AchWorks.s_s_s,
29
+ LocID: AchClient::AchWorks.loc_i_d,
30
+ ACHFileName: nil, # Docs say to leave this blank...
31
+ TotalNumRecords: total_number_records,
32
+ TotalDebitRecords: total_debit_records,
33
+ TotalDebitAmount: total_debit_amount,
34
+ TotalCreditRecords: total_credit_records,
35
+ TotalCreditAmount: total_credit_amount,
36
+ ACHRecords: {
37
+ ACHTransRecord: @ach_transactions.map(&:to_hash)
38
+ }
39
+ }
40
+ }
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ def external_ach_ids
47
+ @ach_transactions.map(&:external_ach_id)
48
+ end
49
+
50
+ # Because AchWorks can't count...
51
+ def total_number_records
52
+ @ach_transactions.count
53
+ end
54
+
55
+ # Because AchWorks can't filter...
56
+ def debit_records
57
+ @ach_transactions.select(&:debit?)
58
+ end
59
+
60
+ def credit_records
61
+ @ach_transactions.select(&:credit?)
62
+ end
63
+
64
+ # They still don't know how to count.
65
+ def total_debit_records
66
+ debit_records.count
67
+ end
68
+
69
+ def total_credit_records
70
+ credit_records.count
71
+ end
72
+
73
+ # "Plusing numbers" is real hard as well.
74
+ def total_debit_amount
75
+ total_amount(debit_records)
76
+ end
77
+
78
+ def total_credit_amount
79
+ total_amount(credit_records)
80
+ end
81
+
82
+ def total_amount(ach_transactions)
83
+ ach_transactions.reduce(0) do |sum, transaction|
84
+ sum + transaction.amount
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,86 @@
1
+ module AchClient
2
+ class AchWorks
3
+ # Poll AchWorks for status of processed or processing Ach transactions.
4
+ class AchStatusChecker < Abstract::AchStatusChecker
5
+ ##
6
+ # Gets the most recent "unread" ach statuses from AchWorks.
7
+ # NOT IDEMPOTENT - Once this method is called (successfully or
8
+ # unsuccessfully), AchWorks will never return the same Ach transactions
9
+ # here again.
10
+ # Wraps: http://tstsvr.achworks.com/dnet/achws.asmx?op=GetACHReturns
11
+ # @return [Hash{String => AchClient::AchResponse}] Hash with FrontEndTrace
12
+ # values as keys, AchResponse objects as values
13
+ def self.most_recent
14
+ request_and_process_response(
15
+ method: :get_ach_returns,
16
+ message: most_recent_hash
17
+ )
18
+ end
19
+
20
+ ##
21
+ # Gets the status of ach transactions between the given dates
22
+ # Sometimes AchWorks will modify the end date...
23
+ # Wraps: http://tstsvr.achworks.com/dnet/achws.asmx?op=GetACHReturnsHist
24
+ # BEWARE OF LARGE DATE RANGES: AchWorks doesn't appear to cache or even
25
+ # paginate their responses, so if your range has too many responses,
26
+ # something will probably break.
27
+ # @param start_date [String] lower bound of date ranged status query
28
+ # @param end_date [String] upper bound of date ranged status query
29
+ # @return [Hash{String => AchClient::AchResponse}] Hash with FrontEndTrace
30
+ # values as keys, AchResponse objects as values
31
+ def self.in_range(start_date:, end_date:)
32
+ request_and_process_response(
33
+ method: :get_ach_returns_hist,
34
+ message: in_range_hash(start_date: start_date, end_date: end_date)
35
+ )
36
+ end
37
+
38
+ private_class_method def self.request_and_process_response(
39
+ method:,
40
+ message:
41
+ )
42
+ response = AchClient::AchWorks.wrap_request(
43
+ method: method,
44
+ message: message,
45
+ path: ['_response', '_result'].map do |postfix|
46
+ (method.to_s + postfix).to_sym
47
+ end
48
+ )
49
+ if response[:total_num_records] == '0'
50
+ []
51
+ else
52
+ response[:ach_return_records][:ach_return_record].select do |record|
53
+ # Exclude records with no front end trace
54
+ # They are probably 9BNK response codes, not actual transactions
55
+ # 9BNK is when AchWorks gives us an aggregate record, containing
56
+ # the total debit/credit to your actual bank account.
57
+ # We don't care about those here.
58
+ record[:front_end_trace].present?
59
+ end.map do |record|
60
+ {
61
+ record[:front_end_trace][1..-1] =>
62
+ AchClient::AchWorks::ResponseRecordProcessor
63
+ .process_response_record(record)
64
+ }
65
+ end.reduce(&:merge)
66
+ end
67
+ end
68
+
69
+ private_class_method def self.company_info
70
+ AchClient::AchWorks::CompanyInfo.build
71
+ end
72
+
73
+ private_class_method def self.most_recent_hash
74
+ company_info.to_hash
75
+ end
76
+
77
+ private_class_method def self.in_range_hash(start_date:, end_date:)
78
+ company_info.to_hash.merge({
79
+ ReturnDateFrom:
80
+ AchClient::AchWorks::DateFormatter.format(start_date),
81
+ ReturnDateTo: AchClient::AchWorks::DateFormatter.format(end_date)
82
+ })
83
+ end
84
+ end
85
+ end
86
+ end