wire_client 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +35 -0
  3. data/.editorconfig +13 -0
  4. data/.gitignore +11 -0
  5. data/.ruby-version +1 -0
  6. data/.tool-versions +1 -0
  7. data/.tool-versions-e +1 -0
  8. data/Gemfile +4 -0
  9. data/LICENSE +22 -0
  10. data/README.md +46 -0
  11. data/Rakefile +27 -0
  12. data/bin/setup +8 -0
  13. data/lib/wire_client/account/account.rb +59 -0
  14. data/lib/wire_client/account/creditor_account.rb +7 -0
  15. data/lib/wire_client/account/debitor_account.rb +7 -0
  16. data/lib/wire_client/base/constants.rb +61 -0
  17. data/lib/wire_client/base/converters.rb +46 -0
  18. data/lib/wire_client/base/invalid_wire_transaction_error.rb +4 -0
  19. data/lib/wire_client/base/invalid_wire_transaction_type_error.rb +3 -0
  20. data/lib/wire_client/base/validators.rb +95 -0
  21. data/lib/wire_client/messages/credit_transfer.rb +112 -0
  22. data/lib/wire_client/messages/direct_debit.rb +148 -0
  23. data/lib/wire_client/messages/message.rb +215 -0
  24. data/lib/wire_client/providers/base/wire_batch.rb +120 -0
  25. data/lib/wire_client/providers/sftp/sftp_provider.rb +39 -0
  26. data/lib/wire_client/providers/sftp/wire_batch.rb +39 -0
  27. data/lib/wire_client/transaction/credit_transfer_transaction.rb +20 -0
  28. data/lib/wire_client/transaction/direct_debit_transaction.rb +49 -0
  29. data/lib/wire_client/transaction/transaction.rb +87 -0
  30. data/lib/wire_client/version.rb +4 -0
  31. data/lib/wire_client.rb +36 -0
  32. data/schemas/.gitattributes +1 -0
  33. data/schemas/pain.001.001.03.xsd +921 -0
  34. data/schemas/pain.008.001.02.xsd +879 -0
  35. data/wire_client.gemspec +66 -0
  36. metadata +375 -0
@@ -0,0 +1,148 @@
1
+ require_relative './message'
2
+ require_relative '../transaction/direct_debit_transaction'
3
+
4
+ module WireClient
5
+ class DirectDebit < Message
6
+ self.account_class = CreditorAccount
7
+ self.transaction_class = DirectDebitTransaction
8
+ self.xml_main_tag = 'CstmrDrctDbtInitn'
9
+ self.known_schemas = [WireClient::PAIN_008_001_02]
10
+
11
+ validate do |record|
12
+ if record.transactions.map(&:local_instrument).uniq.size > 1
13
+ errors.add(
14
+ :base,
15
+ 'CORE, COR1 and B2B must not be mixed in one message!'
16
+ )
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ # Find groups of transactions which share the same values for
23
+ # selected attributes
24
+ def transaction_group(transaction)
25
+ {
26
+ requested_date: transaction.requested_date,
27
+ local_instrument: transaction.local_instrument,
28
+ sequence_type: transaction.sequence_type,
29
+ batch_booking: transaction.batch_booking,
30
+ account: transaction.creditor_account || account,
31
+ service_priority: transaction.service_priority,
32
+ service_level: transaction.service_level
33
+ }
34
+ end
35
+
36
+ def build_payment_information(builder)
37
+ # Build a PmtInf block for every group of transactions
38
+ grouped_transactions.each do |group, transactions|
39
+ builder.PmtInf do
40
+ builder.PmtInfId(payment_information_identification(group))
41
+ builder.PmtMtd('TRF')
42
+ builder.BtchBookg(group[:batch_booking])
43
+ builder.NbOfTxs(transactions.length)
44
+ builder.CtrlSum('%.2f' % amount_total(transactions))
45
+ builder.PmtTpInf do
46
+ builder.InstrPrty(group[:service_priority])
47
+ builder.SvcLvl do
48
+ builder.Cd(group[:service_level])
49
+ end
50
+ builder.LclInstrm do
51
+ builder.Cd(group[:local_instrument])
52
+ end
53
+ builder.SeqTp(group[:sequence_type])
54
+ end
55
+ builder.ReqdColltnDt(group[:requested_date].iso8601)
56
+ builder.Cdtr do
57
+ builder.Nm(group[:account].name)
58
+ builder.PstlAdr do
59
+ builder.CtrySubDvsn(account.country_subdivision_name)
60
+ builder.Ctry(account.country)
61
+ end
62
+ end
63
+ builder.CdtrAcct do
64
+ account_id(builder, group[:account])
65
+ end
66
+ builder.CdtrAgt do
67
+ builder.FinInstnId do
68
+ account_agent_id(builder, group[:account])
69
+ end
70
+ end
71
+
72
+ if account.charge_bearer
73
+ builder.ChrgBr(account.charge_bearer)
74
+ end
75
+
76
+ transactions.each do |transaction|
77
+ build_transaction(builder, transaction)
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ def build_amendment_informations(builder, transaction)
84
+ return unless transaction.original_debtor_account ||
85
+ transaction.same_mandate_new_debtor_agent
86
+ builder.AmdmntInd(true)
87
+ builder.AmdmntInfDtls do
88
+ if transaction.original_debtor_account
89
+ builder.OrgnlDbtrAcct do
90
+ builder.Id do
91
+ builder.IBAN(transaction.original_debtor_account)
92
+ end
93
+ end
94
+ else
95
+ builder.OrgnlDbtrAgt do
96
+ builder.FinInstnId do
97
+ builder.Othr do
98
+ builder.Id('SMNDA')
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ def build_transaction(builder, transaction)
107
+ builder.DrctDbtTxInf do
108
+ builder.PmtId do
109
+ if transaction.instruction.present?
110
+ builder.InstrId(transaction.instruction)
111
+ end
112
+ builder.EndToEndId(transaction.reference)
113
+ end
114
+ builder.InstdAmt(
115
+ '%.2f' % transaction.amount,
116
+ Ccy: transaction.currency
117
+ )
118
+ builder.DrctDbtTx do
119
+ builder.MndtRltdInf do
120
+ builder.MndtId(transaction.mandate_id)
121
+ builder.DtOfSgntr(transaction.mandate_date_of_signature.iso8601)
122
+ build_amendment_informations(builder, transaction)
123
+ end
124
+ end
125
+ builder.DbtrAgt do
126
+ builder.FinInstnId do
127
+ transaction_agent_id(builder, transaction)
128
+ builder.Nm(transaction.agent_name)
129
+ builder.PstlAdr do
130
+ builder.Ctry(transaction.country)
131
+ end
132
+ end
133
+ end
134
+ builder.Dbtr do
135
+ builder.Nm(transaction.name)
136
+ end
137
+ builder.DbtrAcct do
138
+ transaction_account_id(builder, transaction)
139
+ end
140
+ if transaction.remittance_information
141
+ builder.RmtInf do
142
+ builder.Ustrd(transaction.remittance_information)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,215 @@
1
+ module WireClient
2
+ class Message
3
+ include ActiveModel::Validations
4
+
5
+ attr_reader :account, :grouped_transactions
6
+
7
+ validates_presence_of :transactions
8
+ validate do |record|
9
+ unless record.account.valid?
10
+ record.errors.add(:account, record.account.errors.full_messages)
11
+ end
12
+ end
13
+
14
+ class_attribute :account_class,
15
+ :transaction_class,
16
+ :xml_main_tag,
17
+ :known_schemas
18
+
19
+ def initialize(account_options={})
20
+ @grouped_transactions = {}
21
+ @account = account_class.new(account_options)
22
+ end
23
+
24
+ def add_transaction(options)
25
+ transaction = transaction_class.new(options)
26
+ unless transaction.valid?
27
+ raise ArgumentError, transaction.error_messages
28
+ end
29
+ @grouped_transactions[transaction_group(transaction)] ||= []
30
+ @grouped_transactions[transaction_group(transaction)] << transaction
31
+ end
32
+
33
+ def transactions
34
+ grouped_transactions.values.flatten
35
+ end
36
+
37
+ # @return [String] xml
38
+ def to_xml(schema_name=self.class.known_schemas.first)
39
+ raise(RuntimeError, errors.full_messages.join("\n")) unless valid?
40
+ unless schema_compatible?(schema_name)
41
+ raise RuntimeError, "Incompatible with schema #{schema_name}!"
42
+ end
43
+
44
+ builder = Builder::XmlMarkup.new indent: 2
45
+ builder.instruct! :xml
46
+ builder.Document(xml_schema(schema_name)) do
47
+ builder.__send__(self.class.xml_main_tag) do
48
+ build_group_header(builder)
49
+ build_payment_information(builder)
50
+ end
51
+ end
52
+ end
53
+
54
+ def amount_total(selected_transactions=transactions)
55
+ selected_transactions.inject(0) { |sum, t| sum + t.amount }
56
+ end
57
+
58
+ def schema_compatible?(schema_name)
59
+ unless self.known_schemas.include?(schema_name)
60
+ raise ArgumentError, "Schema #{schema_name} is unknown!"
61
+ end
62
+
63
+ transactions.all? { |t| t.schema_compatible?(schema_name) }
64
+ end
65
+
66
+ # Set unique identifer for the message
67
+ def message_identification=(value)
68
+ unless value.is_a?(String)
69
+ raise ArgumentError, 'mesage_identification must be a string!'
70
+ end
71
+
72
+ regex = /\A([A-Za-z0-9]|[\+|\?|\/|\-|\:|\(|\)|\.|\,|\'|\ ]){1,35}\z/
73
+ unless value.match(regex)
74
+ raise ArgumentError, "mesage_identification does not match #{regex}!"
75
+ end
76
+
77
+ @message_identification = value
78
+ end
79
+
80
+ # Get unique identifer for the message (with fallback to a random string)
81
+ def message_identification
82
+ @message_identification ||= "WIRE/#{SecureRandom.hex(5)}"
83
+ end
84
+
85
+ # Returns the id of the batch to which the given transaction belongs
86
+ # Identified based upon the reference of the transaction
87
+ def batch_id(transaction_reference)
88
+ grouped_transactions.each do |group, transactions|
89
+ selected_transactions = begin
90
+ transactions.select do |transaction|
91
+ transaction.reference == transaction_reference
92
+ end
93
+ end
94
+ if selected_transactions.any?
95
+ return payment_information_identification(group)
96
+ end
97
+ end
98
+ end
99
+
100
+ def batches
101
+ grouped_transactions.keys.collect do |group|
102
+ payment_information_identification(group)
103
+ end
104
+ end
105
+
106
+ def error_messages
107
+ errors.full_messages.join("\n")
108
+ end
109
+
110
+ private
111
+
112
+ # @return {Hash<Symbol=>String>} xml schema information used in output xml
113
+ def xml_schema(schema_name)
114
+ urn = "urn:iso:std:iso:20022:tech:xsd:#{schema_name}"
115
+ {
116
+ :xmlns => "#{urn}",
117
+ :'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance',
118
+ :'xsi:schemaLocation' => "#{urn} #{schema_name}.xsd"
119
+ }
120
+ end
121
+
122
+ def build_group_header(builder)
123
+ builder.GrpHdr do
124
+ builder.MsgId(message_identification)
125
+ builder.CreDtTm(Time.now.iso8601)
126
+ builder.NbOfTxs(transactions.length)
127
+ builder.CtrlSum('%.2f' % amount_total)
128
+ builder.InitgPty do
129
+ builder.Nm(account.name)
130
+ builder.Id do
131
+ builder.OrgId do
132
+ builder.Othr do
133
+ builder.Id(account.identifier)
134
+ builder.SchmeNm do
135
+ builder.Cd(account.schema_code)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ # Unique and consecutive identifier (used for the <PmntInf> blocks)
145
+ def payment_information_identification(group)
146
+ "#{message_identification}/#{grouped_transactions.keys.index(group) + 1}"
147
+ end
148
+
149
+ # Returns a key to determine the group to which the transaction belongs
150
+ def transaction_group(transaction)
151
+ transaction
152
+ end
153
+
154
+ def account_agent_id(builder, account)
155
+ if account.bic
156
+ builder.BIC(account.bic)
157
+ else
158
+ builder.ClrSysMmbId do
159
+ builder.ClrSysId do
160
+ builder.Cd(account.clear_system_code)
161
+ end
162
+ builder.MmbId(account.wire_routing_number)
163
+ end
164
+ end
165
+ end
166
+
167
+ def account_id(builder, account)
168
+ if account.iban
169
+ builder.Id do
170
+ builder.IBAN(account.iban)
171
+ end
172
+ else
173
+ builder.Id do
174
+ builder.Othr do
175
+ builder.Id(account.account_number)
176
+ end
177
+ end
178
+ builder.Tp do
179
+ builder.Cd('CACC')
180
+ end
181
+ builder.Ccy(account.currency)
182
+ end
183
+ end
184
+
185
+ def transaction_agent_id(builder, transaction)
186
+ if transaction.bic
187
+ builder.BIC(transaction.bic)
188
+ else
189
+ builder.ClrSysMmbId do
190
+ builder.ClrSysId do
191
+ builder.Cd(transaction.clear_system_code)
192
+ end
193
+ builder.MmbId(transaction.wire_routing_number)
194
+ end
195
+ end
196
+ end
197
+
198
+ def transaction_account_id(builder, transaction)
199
+ if transaction.iban
200
+ builder.Id do
201
+ builder.IBAN(transaction.iban)
202
+ end
203
+ else
204
+ builder.Id do
205
+ builder.Othr do
206
+ builder.Id(transaction.account_number)
207
+ end
208
+ end
209
+ builder.Tp do
210
+ builder.Cd('CACC')
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,120 @@
1
+ module WireClient
2
+ # Abstract Wire transfer provider, which all other providers inherit from
3
+ class Abstract
4
+
5
+ # Base class for sending batched Wire transfer transactions to various
6
+ # providers
7
+ class WireBatch
8
+ # An initiator is an entity that initiates the transfer process; it can
9
+ # be a debtor or a creditor. A receptor, on the other hand, is someone
10
+ # who will fulfill the transfer, whether receiving or withdrawing the
11
+ # described amount.
12
+
13
+ # @return [String] Debtor's or creditor's name for the one initianting
14
+ # the transfer
15
+ class_attribute :initiator_name
16
+
17
+ # @return [String] Debtor's or creditor's IBAN (International Bank
18
+ # Account Number) ID
19
+ class_attribute :initiator_iban
20
+
21
+ # @return [String] Debtor's or creditor's bank SWIFT Code / BIC
22
+ class_attribute :initiator_bic
23
+
24
+ # @return [String] Debtor's or creditor's Account Number
25
+ class_attribute :initiator_account_number
26
+
27
+ # @return [String] Debtor or creditor agent's wire routing number
28
+ class_attribute :initiator_wire_routing_number
29
+
30
+ # @return [String] The initiating party's Identifier
31
+ class_attribute :initiator_identifier
32
+
33
+ # @return [String] The initiating party's country (2 character country
34
+ # code; default: US)
35
+ class_attribute :initiator_country
36
+
37
+ # @return [String] The initiating party's country subdivision (name or
38
+ # 2 character code; default: MA)
39
+ class_attribute :initiator_country_subdivision
40
+
41
+ ##
42
+ # @return [Array] A list of arguments to use in the initializer, and as
43
+ # instance attributes
44
+ def self.arguments
45
+ [
46
+ :transaction_type
47
+ ]
48
+ end
49
+
50
+ attr_reader(*arguments)
51
+
52
+ ##
53
+ # @param transaction_type [WireClient::TransactionTypes::TransactionType]
54
+ # debit or credit
55
+ def initialize(*arguments)
56
+ args = arguments.extract_options!
57
+ self.class.arguments.each do |param|
58
+ self.instance_variable_set(
59
+ "@#{param}".to_sym,
60
+ args[param]
61
+ )
62
+ end
63
+ if @transaction_type == WireClient::TransactionTypes::Credit
64
+ initialize_payment_initiation(CreditTransfer)
65
+ elsif @transaction_type == WireClient::TransactionTypes::Debit
66
+ initialize_payment_initiation(DirectDebit)
67
+ else
68
+ raise InvalidWireTransactionTypeError,
69
+ 'Transaction type should be explicitly defined'
70
+ end
71
+ end
72
+
73
+ def add_transaction(transaction_options)
74
+ @payment_initiation.add_transaction(transaction_options)
75
+ end
76
+
77
+ ##
78
+ # Sends the batch to the provider. Useful to check transaction status
79
+ # before sending any data (consistency, validation, etc.)
80
+ def send_batch
81
+ if @payment_initiation.valid?
82
+ do_send_batch
83
+ else
84
+ raise InvalidWireTransactionError,
85
+ "invalid wire transfer: #{@payment_initiation.error_messages}"
86
+ end
87
+ end
88
+
89
+ # Implementation of sending the Wire transfer batch to the provider, to be
90
+ # implemented by the subclass
91
+ def do_send_batch
92
+ raise AbstractMethodError
93
+ end
94
+
95
+ private
96
+
97
+ def initialize_payment_initiation(klass)
98
+ if self.class.initiator_iban
99
+ @payment_initiation = klass.new(
100
+ name: self.class.initiator_name,
101
+ bic: self.class.initiator_bic,
102
+ iban: self.class.initiator_iban,
103
+ identifier: self.class.initiator_identifier,
104
+ country: self.class.initiator_country,
105
+ country_subdivision: self.class.initiator_country_subdivision
106
+ )
107
+ else
108
+ @payment_initiation = klass.new(
109
+ name: self.class.initiator_name,
110
+ wire_routing_number: self.class.initiator_wire_routing_number,
111
+ account_number: self.class.initiator_account_number,
112
+ identifier: self.class.initiator_identifier,
113
+ country: self.class.initiator_country,
114
+ country_subdivision: self.class.initiator_country_subdivision
115
+ )
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,39 @@
1
+ module WireClient
2
+ # Base concern for providers like SVB that use an SFTP system instead of API
3
+ module SftpProvider
4
+ extend ActiveSupport::Concern
5
+ include AchClient::SftpProvider
6
+
7
+ included do
8
+ # @return [String] Hostname/URL of SVB's "Direct File Transmission" server
9
+ class_attribute :host
10
+
11
+ # @return [String] The username they gave you to login to the server
12
+ class_attribute :username
13
+
14
+ # @return [String] The password they gave you to login to the server
15
+ class_attribute :password
16
+
17
+ # @return [String] The private ssh key that matches the public ssh key you
18
+ # provided to SVB, ie the output of `cat path/to/private/ssh/key`
19
+ class_attribute :private_ssh_key
20
+
21
+ # @return [String | NilClass] Passphrase for your private ssh key
22
+ # (if applicable)
23
+ class_attribute :passphrase
24
+
25
+ # @return [String] The path on the remote server to the directory where
26
+ # you will deposit your outgoing NACHA files
27
+ class_attribute :outgoing_path
28
+
29
+ # @return [String] The path on the remote server to the directory where
30
+ # the SFTP provider will deposit return/confirmation files
31
+ class_attribute :incoming_path
32
+
33
+ # @return [Proc] A function that defines the filenaming strategy for your
34
+ # provider. The function should take an optional batch number and return
35
+ # a filename string
36
+ class_attribute :file_naming_strategy
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,39 @@
1
+ module WireClient
2
+ # Namespace for all things Sftp
3
+ class Sftp
4
+ # NACHA representation of an AchBatch
5
+ class WireBatch < WireClient::Abstract::WireBatch
6
+
7
+ def initialize(transaction_type:, batch_number: nil)
8
+ super(transaction_type: transaction_type)
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
+ def do_send_batch
20
+ file_path = File.join(
21
+ self.class.parent.outgoing_path,
22
+ batch_file_name
23
+ )
24
+ file_body = begin
25
+ if @transaction_type == WireClient::TransactionTypes::Credit
26
+ @payment_initiation.to_xml('pain.001.001.03').to_s
27
+ else
28
+ @payment_initiation.to_xml('pain.008.001.02').to_s
29
+ end
30
+ end
31
+ self.class.parent.write_remote_file(
32
+ file_path: file_path,
33
+ file_body: file_body
34
+ )
35
+ [file_path, file_body]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,20 @@
1
+ require_relative './transaction'
2
+
3
+ module WireClient
4
+ class CreditTransferTransaction < Transaction
5
+ attr_accessor :service_priority, :service_level
6
+
7
+ validates_inclusion_of :service_priority, :in => SERVICE_PRIORITY_TYPES
8
+ validates_inclusion_of :service_level, :in => SERVICE_LEVEL_TYPES
9
+
10
+ validate do |t|
11
+ t.validate_requested_date_after(Time.zone.now.to_date.next)
12
+ end
13
+
14
+ def initialize(attributes = {})
15
+ super
16
+ @service_priority ||= 'NORM'
17
+ @service_level ||= 'URGP'
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,49 @@
1
+ require_relative './transaction'
2
+
3
+ module WireClient
4
+ class DirectDebitTransaction < Transaction
5
+
6
+ attr_accessor :mandate_id,
7
+ :mandate_date_of_signature,
8
+ :local_instrument,
9
+ :sequence_type,
10
+ :creditor_account,
11
+ :original_debtor_account,
12
+ :same_mandate_new_debtor_agent,
13
+ :service_priority,
14
+ :service_level
15
+
16
+ validates_with MandateIdentifierValidator,
17
+ field_name: :mandate_id,
18
+ message: "%{value} is invalid"
19
+ validates_presence_of :mandate_date_of_signature
20
+ validates_inclusion_of :local_instrument, in: LOCAL_INSTRUMENTS
21
+ validates_inclusion_of :sequence_type, in: SEQUENCE_TYPES
22
+ validates_inclusion_of :service_priority, :in => SERVICE_PRIORITY_TYPES
23
+ validates_inclusion_of :service_level, :in => SERVICE_LEVEL_TYPES
24
+
25
+ validate do |t|
26
+ t.validate_requested_date_after(Time.zone.now.to_date.next)
27
+ end
28
+
29
+ validate do |t|
30
+ if creditor_account && !creditor_account.valid?
31
+ errors.add(:creditor_account, 'is not correct')
32
+ end
33
+
34
+ if t.mandate_date_of_signature.is_a?(Date)
35
+ if t.mandate_date_of_signature > Time.zone.now.to_date
36
+ errors.add(:mandate_date_of_signature, 'is in the future')
37
+ end
38
+ else
39
+ errors.add(:mandate_date_of_signature, 'is not a Date')
40
+ end
41
+ end
42
+
43
+ def initialize(attributes = {})
44
+ super
45
+ @local_instrument ||= 'B2B'
46
+ @sequence_type ||= 'OOFF'
47
+ end
48
+ end
49
+ end