ach_client 0.5.1

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