wire_client 0.1.0

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 (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