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.
- checksums.yaml +7 -0
- data/.codeclimate.yml +18 -0
- data/.gitignore +9 -0
- data/.rubocop.yml +1156 -0
- data/.ruby-version +1 -0
- data/.travis.yml +10 -0
- data/Gemfile +4 -0
- data/README.md +388 -0
- data/Rakefile +10 -0
- data/ach_client.gemspec +58 -0
- data/bin/console +93 -0
- data/bin/setup +8 -0
- data/config/return_codes.yml +761 -0
- data/lib/ach_client/abstract/abstract_method_error.rb +3 -0
- data/lib/ach_client/helpers/dollars_to_cents.rb +15 -0
- data/lib/ach_client/logging/log_provider_job.rb +36 -0
- data/lib/ach_client/logging/log_providers/log_provider.rb +22 -0
- data/lib/ach_client/logging/log_providers/null_log_provider.rb +14 -0
- data/lib/ach_client/logging/log_providers/stdout_log_provider.rb +14 -0
- data/lib/ach_client/logging/logging.rb +76 -0
- data/lib/ach_client/logging/savon_observer.rb +26 -0
- data/lib/ach_client/objects/account_types.rb +32 -0
- data/lib/ach_client/objects/responses/ach_response.rb +15 -0
- data/lib/ach_client/objects/responses/corrected_ach_response.rb +18 -0
- data/lib/ach_client/objects/responses/processing_ach_response.rb +6 -0
- data/lib/ach_client/objects/responses/returned_ach_response.rb +16 -0
- data/lib/ach_client/objects/responses/settled_ach_response.rb +5 -0
- data/lib/ach_client/objects/return_code.rb +20 -0
- data/lib/ach_client/objects/return_codes.rb +33 -0
- data/lib/ach_client/objects/transaction_types.rb +16 -0
- data/lib/ach_client/providers/abstract/ach_batch.rb +23 -0
- data/lib/ach_client/providers/abstract/ach_status_checker.rb +21 -0
- data/lib/ach_client/providers/abstract/ach_transaction.rb +70 -0
- data/lib/ach_client/providers/abstract/company_info.rb +28 -0
- data/lib/ach_client/providers/abstract/response_record_processor.rb +15 -0
- data/lib/ach_client/providers/abstract/transformer.rb +38 -0
- data/lib/ach_client/providers/sftp/account_type_transformer.rb +20 -0
- data/lib/ach_client/providers/sftp/ach_batch.rb +97 -0
- data/lib/ach_client/providers/sftp/ach_status_checker.rb +146 -0
- data/lib/ach_client/providers/sftp/ach_transaction.rb +62 -0
- data/lib/ach_client/providers/sftp/nacha_provider.rb +30 -0
- data/lib/ach_client/providers/sftp/sftp_provider.rb +124 -0
- data/lib/ach_client/providers/sftp/transaction_type_transformer.rb +19 -0
- data/lib/ach_client/providers/soap/ach_works/account_type_transformer.rb +17 -0
- data/lib/ach_client/providers/soap/ach_works/ach_batch.rb +89 -0
- data/lib/ach_client/providers/soap/ach_works/ach_status_checker.rb +86 -0
- data/lib/ach_client/providers/soap/ach_works/ach_transaction.rb +80 -0
- data/lib/ach_client/providers/soap/ach_works/ach_works.rb +57 -0
- data/lib/ach_client/providers/soap/ach_works/company_info.rb +87 -0
- data/lib/ach_client/providers/soap/ach_works/correction_details_processor.rb +92 -0
- data/lib/ach_client/providers/soap/ach_works/date_formatter.rb +33 -0
- data/lib/ach_client/providers/soap/ach_works/response_record_processor.rb +91 -0
- data/lib/ach_client/providers/soap/ach_works/transaction_type_transformer.rb +18 -0
- data/lib/ach_client/providers/soap/i_check_gateway/account_type_transformer.rb +20 -0
- data/lib/ach_client/providers/soap/i_check_gateway/ach_batch.rb +12 -0
- data/lib/ach_client/providers/soap/i_check_gateway/ach_status_checker.rb +35 -0
- data/lib/ach_client/providers/soap/i_check_gateway/ach_transaction.rb +53 -0
- data/lib/ach_client/providers/soap/i_check_gateway/company_info.rb +51 -0
- data/lib/ach_client/providers/soap/i_check_gateway/i_check_gateway.rb +39 -0
- data/lib/ach_client/providers/soap/i_check_gateway/response_record_processor.rb +59 -0
- data/lib/ach_client/providers/soap/i_check_gateway/transaction_type_transformer.rb +8 -0
- data/lib/ach_client/providers/soap/soap_provider.rb +47 -0
- data/lib/ach_client/version.rb +4 -0
- data/lib/ach_client.rb +38 -0
- 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
|