sps_king 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +32 -0
  5. data/CONTRIBUTING.md +38 -0
  6. data/Gemfile +2 -0
  7. data/LICENSE.txt +24 -0
  8. data/README.md +246 -0
  9. data/Rakefile +6 -0
  10. data/gemfiles/Gemfile-activemodel-3.1.x +5 -0
  11. data/gemfiles/Gemfile-activemodel-3.2.x +5 -0
  12. data/gemfiles/Gemfile-activemodel-4.0.x +5 -0
  13. data/gemfiles/Gemfile-activemodel-4.1.x +5 -0
  14. data/gemfiles/Gemfile-activemodel-4.2.x +5 -0
  15. data/gemfiles/Gemfile-activemodel-5.0.x +5 -0
  16. data/gemfiles/Gemfile-activemodel-5.1.x +5 -0
  17. data/gemfiles/Gemfile-activemodel-5.2.x +5 -0
  18. data/lib/schema/pain.001.001.03.ch.02.xsd +1212 -0
  19. data/lib/schema/pain.008.001.02.ch.03.xsd +784 -0
  20. data/lib/sps_king.rb +22 -0
  21. data/lib/sps_king/account.rb +24 -0
  22. data/lib/sps_king/account/creditor_account.rb +11 -0
  23. data/lib/sps_king/account/creditor_address.rb +37 -0
  24. data/lib/sps_king/account/debtor_account.rb +5 -0
  25. data/lib/sps_king/account/debtor_address.rb +34 -0
  26. data/lib/sps_king/converter.rb +53 -0
  27. data/lib/sps_king/error.rb +4 -0
  28. data/lib/sps_king/message.rb +163 -0
  29. data/lib/sps_king/message/credit_transfer.rb +140 -0
  30. data/lib/sps_king/message/direct_debit.rb +177 -0
  31. data/lib/sps_king/structured_remittance_information.rb +20 -0
  32. data/lib/sps_king/transaction.rb +59 -0
  33. data/lib/sps_king/transaction/credit_transfer_transaction.rb +19 -0
  34. data/lib/sps_king/transaction/direct_debit_transaction.rb +58 -0
  35. data/lib/sps_king/validator.rb +55 -0
  36. data/lib/sps_king/version.rb +3 -0
  37. data/spec/examples/pain.001.001.03.ch.02.xml +172 -0
  38. data/spec/examples/pain.008.001.02.ch.03.xml +240 -0
  39. data/spec/lib/sps_king/account/creditor_account_spec.rb +52 -0
  40. data/spec/lib/sps_king/account/creditor_address_spec.rb +14 -0
  41. data/spec/lib/sps_king/account/debtor_account_spec.rb +14 -0
  42. data/spec/lib/sps_king/account/debtor_address_spec.rb +14 -0
  43. data/spec/lib/sps_king/account_spec.rb +42 -0
  44. data/spec/lib/sps_king/converter_spec.rb +88 -0
  45. data/spec/lib/sps_king/message/credit_transfer_spec.rb +350 -0
  46. data/spec/lib/sps_king/message/direct_debit_spec.rb +483 -0
  47. data/spec/lib/sps_king/message_spec.rb +128 -0
  48. data/spec/lib/sps_king/structured_remittance_information_spec.rb +32 -0
  49. data/spec/lib/sps_king/transaction/credit_transfer_transaction_spec.rb +53 -0
  50. data/spec/lib/sps_king/transaction/direct_debit_transaction_spec.rb +56 -0
  51. data/spec/lib/sps_king/transaction_spec.rb +133 -0
  52. data/spec/lib/sps_king/validation_spec.rb +23 -0
  53. data/spec/lib/sps_king/validator_spec.rb +78 -0
  54. data/spec/spec_helper.rb +33 -0
  55. data/spec/support/active_model.rb +30 -0
  56. data/spec/support/custom_matcher.rb +60 -0
  57. data/spec/support/factories.rb +33 -0
  58. data/spec/support/validations.rb +27 -0
  59. data/sps_king.gemspec +31 -0
  60. metadata +237 -0
@@ -0,0 +1,22 @@
1
+ require 'active_model'
2
+ require 'bigdecimal'
3
+ require 'date'
4
+ require 'nokogiri'
5
+ require 'iban-tools'
6
+
7
+ require 'sps_king/error'
8
+ require 'sps_king/converter'
9
+ require 'sps_king/validator'
10
+ require 'sps_king/version'
11
+ require 'sps_king/account'
12
+ require 'sps_king/account/debtor_account'
13
+ require 'sps_king/account/debtor_address'
14
+ require 'sps_king/account/creditor_account'
15
+ require 'sps_king/account/creditor_address'
16
+ require 'sps_king/structured_remittance_information'
17
+ require 'sps_king/transaction'
18
+ require 'sps_king/transaction/direct_debit_transaction'
19
+ require 'sps_king/transaction/credit_transfer_transaction'
20
+ require 'sps_king/message'
21
+ require 'sps_king/message/direct_debit'
22
+ require 'sps_king/message/credit_transfer'
@@ -0,0 +1,24 @@
1
+ # encoding: utf-8
2
+ module SPS
3
+ class Account
4
+ include ActiveModel::Validations
5
+ extend Converter
6
+
7
+ attr_accessor :name,
8
+ :iban,
9
+ :bic
10
+
11
+ convert :name, to: :text
12
+
13
+ validates_length_of :name, within: 1..70
14
+ validates_with BICValidator,
15
+ IBANValidator,
16
+ message: "%{value} is invalid"
17
+
18
+ def initialize(attributes = {})
19
+ attributes.each do |name, value|
20
+ public_send("#{name}=", value)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,11 @@
1
+ # encoding: utf-8
2
+ module SPS
3
+ class CreditorAccount < Account
4
+ attr_accessor :creditor_identifier,
5
+ :isr_participant_number
6
+
7
+ validates_with CreditorIdentifierValidator,
8
+ message: "%{value} is invalid"
9
+ validates_format_of :isr_participant_number, with: /\A\d{9}\z/, allow_nil: true
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ # encoding: utf-8
2
+ module SPS
3
+ class CreditorAddress
4
+ include ActiveModel::Validations
5
+ extend Converter
6
+
7
+ attr_accessor :street_name,
8
+ :building_number,
9
+ :post_code,
10
+ :town_name,
11
+ :country_code,
12
+ :address_line1,
13
+ :address_line2
14
+
15
+ convert :street_name, to: :text
16
+ convert :building_number, to: :text
17
+ convert :post_code, to: :text
18
+ convert :town_name, to: :text
19
+ convert :country_code, to: :text
20
+ convert :address_line1, to: :text
21
+ convert :address_line2, to: :text
22
+
23
+ validates_length_of :street_name, maximum: 70
24
+ validates_length_of :building_number, maximum: 16
25
+ validates_length_of :post_code, maximum: 16
26
+ validates_length_of :town_name, maximum: 35
27
+ validates_length_of :country_code, is: 2
28
+ validates_length_of :address_line1, maximum: 70
29
+ validates_length_of :address_line2, maximum: 70
30
+
31
+ def initialize(attributes = {})
32
+ attributes.each do |name, value|
33
+ public_send("#{name}=", value)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ # encoding: utf-8
2
+ module SPS
3
+ class DebtorAccount < Account
4
+ end
5
+ end
@@ -0,0 +1,34 @@
1
+ # encoding: utf-8
2
+ module SPS
3
+ class DebtorAddress
4
+ include ActiveModel::Validations
5
+ extend Converter
6
+
7
+ attr_accessor :street_name,
8
+ :post_code,
9
+ :town_name,
10
+ :country_code,
11
+ :address_line1,
12
+ :address_line2
13
+
14
+ convert :street_name, to: :text
15
+ convert :post_code, to: :text
16
+ convert :town_name, to: :text
17
+ convert :country_code, to: :text
18
+ convert :address_line1, to: :text
19
+ convert :address_line2, to: :text
20
+
21
+ validates_length_of :street_name, maximum: 70
22
+ validates_length_of :post_code, maximum: 16
23
+ validates_length_of :town_name, maximum: 35
24
+ validates_length_of :country_code, is: 2
25
+ validates_length_of :address_line1, maximum: 70
26
+ validates_length_of :address_line2, maximum: 70
27
+
28
+ def initialize(attributes = {})
29
+ attributes.each do |name, value|
30
+ public_send("#{name}=", value)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,53 @@
1
+ # encoding: utf-8
2
+ module SPS
3
+ module Converter
4
+ def convert(*attributes, options)
5
+ include InstanceMethods
6
+
7
+ method_name = "convert_#{options[:to]}"
8
+ unless InstanceMethods.method_defined?(method_name)
9
+ raise ArgumentError.new("Converter '#{options[:to]}' does not exist!")
10
+ end
11
+
12
+ attributes.each do |attribute|
13
+ define_method "#{attribute}=" do |value|
14
+ instance_variable_set("@#{attribute}", send(method_name, value))
15
+ end
16
+ end
17
+ end
18
+
19
+ module InstanceMethods
20
+ def convert_text(value)
21
+ return unless value
22
+
23
+ value.to_s.
24
+ # Replace some special characters described as "Best practices" in Chapter 6.2 of this document:
25
+ # http://www.europeanpaymentscouncil.eu/index.cfm/knowledge-bank/epc-documents/sepa-requirements-for-an-extended-character-set-unicode-subset-best-practices/
26
+ gsub('€','E').
27
+ gsub('@','(at)').
28
+ gsub('_','-').
29
+
30
+ # Replace linebreaks by spaces
31
+ gsub(/\n+/,' ').
32
+
33
+ # Remove all invalid characters
34
+ gsub(/[^a-zA-Z0-9ÄÖÜäöüß&*$%\ \'\:\?\,\-\(\+\.\)\/]/, '').
35
+
36
+ # Remove leading and trailing spaces
37
+ strip
38
+ end
39
+
40
+ def convert_decimal(value)
41
+ return unless value
42
+ value = begin
43
+ BigDecimal(value.to_s)
44
+ rescue ArgumentError
45
+ end
46
+
47
+ if value && value.finite? && value > 0
48
+ value.round(2)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,4 @@
1
+ module SPS
2
+ class Error < RuntimeError
3
+ end
4
+ end
@@ -0,0 +1,163 @@
1
+ # encoding: utf-8
2
+
3
+ module SPS
4
+ PAIN_008_001_02_CH_03 = 'pain.008.001.02.ch.03'
5
+ PAIN_001_001_03_CH_02 = 'pain.001.001.03.ch.02'
6
+
7
+ class Message
8
+ include ActiveModel::Validations
9
+
10
+ attr_reader :account, :grouped_transactions
11
+
12
+ validates_presence_of :transactions
13
+ validate do |record|
14
+ record.errors.add(:account, record.account.errors.full_messages) unless record.account.valid?
15
+ end
16
+
17
+ class_attribute :account_class, :transaction_class, :xml_main_tag, :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
+ raise ArgumentError.new(transaction.errors.full_messages.join("\n")) unless transaction.valid?
27
+ @grouped_transactions[transaction_group(transaction)] ||= []
28
+ @grouped_transactions[transaction_group(transaction)] << transaction
29
+ end
30
+
31
+ def transactions
32
+ grouped_transactions.values.flatten
33
+ end
34
+
35
+ # @return [String] xml
36
+ def to_xml(schema_name=self.known_schemas.first)
37
+ raise SPS::Error.new(errors.full_messages.join("\n")) unless valid?
38
+ raise SPS::Error.new("Incompatible with schema #{schema_name}!") unless schema_compatible?(schema_name)
39
+
40
+ builder = Nokogiri::XML::Builder.new do |builder|
41
+ builder.Document(xml_schema(schema_name)) do
42
+ builder.__send__(xml_main_tag) do
43
+ build_group_header(builder)
44
+ build_payment_informations(builder)
45
+ end
46
+ end
47
+ end
48
+
49
+ validate_final_document!(builder.doc, schema_name)
50
+ builder.to_xml
51
+ end
52
+
53
+ def amount_total(selected_transactions=transactions)
54
+ selected_transactions.inject(0) { |sum, t| sum + t.amount }
55
+ end
56
+
57
+ def schema_compatible?(schema_name)
58
+ raise ArgumentError.new("Schema #{schema_name} is unknown!") unless self.known_schemas.include?(schema_name)
59
+
60
+ case schema_name
61
+ when PAIN_001_001_03_CH_02
62
+ transactions.all? { |t| t.schema_compatible?(schema_name) }
63
+ when PAIN_008_001_02_CH_03
64
+ transactions.all? { |t| t.schema_compatible?(schema_name) } &&
65
+ !account.iban.to_s.match(/^(CH|LI)/).nil? # Only allowed for switzerland or liechtenstein
66
+ end
67
+ end
68
+
69
+ # Set unique identifer for the message
70
+ def message_identification=(value)
71
+ raise ArgumentError.new('message_identification must be a string!') unless value.is_a?(String)
72
+
73
+ regex = /\A([A-Za-z0-9]|[\+|\?|\/|\-|\:|\(|\)|\.|\,|\'|\ ]){1,35}\z/
74
+ raise ArgumentError.new("message_identification does not match #{regex}!") unless value.match(regex)
75
+
76
+ @message_identification = value
77
+ end
78
+
79
+ # Get unique identifer for the message (with fallback to a random string)
80
+ def message_identification
81
+ @message_identification ||= "SPS-KING/#{SecureRandom.hex(11)}"
82
+ end
83
+
84
+ # Set creation date time for the message
85
+ # p.s. Rabobank in the Netherlands only accepts the more restricted format [0-9]{4}[-][0-9]{2,2}[-][0-9]{2,2}[T][0-9]{2,2}[:][0-9]{2,2}[:][0-9]{2,2}
86
+ def creation_date_time=(value)
87
+ raise ArgumentError.new('creation_date_time must be a string!') unless value.is_a?(String)
88
+
89
+ regex = /[0-9]{4}[-][0-9]{2,2}[-][0-9]{2,2}(?:\s|T)[0-9]{2,2}[:][0-9]{2,2}[:][0-9]{2,2}/
90
+ raise ArgumentError.new("creation_date_time does not match #{regex}!") unless value.match(regex)
91
+
92
+ @creation_date_time = value
93
+ end
94
+
95
+ # Get creation date time for the message (with fallback to Time.now.iso8601)
96
+ def creation_date_time
97
+ @creation_date_time ||= Time.now.iso8601
98
+ end
99
+
100
+ # Returns the id of the batch to which the given transaction belongs
101
+ # Identified based upon the reference of the transaction
102
+ def batch_id(transaction_reference)
103
+ grouped_transactions.each do |group, transactions|
104
+ if transactions.select { |transaction| transaction.reference == transaction_reference }.any?
105
+ return payment_information_identification(group)
106
+ end
107
+ end
108
+ end
109
+
110
+ def batches
111
+ grouped_transactions.keys.collect { |group| payment_information_identification(group) }
112
+ end
113
+
114
+ private
115
+ # @return {Hash<Symbol=>String>} xml schema information used in output xml
116
+ def xml_schema(schema_name)
117
+ {
118
+ :xmlns => "http://www.six-interbank-clearing.com/de/#{schema_name}.xsd",
119
+ :'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance',
120
+ :'xsi:schemaLocation' => "http://www.six-interbank-clearing.com/de/#{schema_name}.xsd #{schema_name}.xsd"
121
+ }
122
+ end
123
+
124
+ def build_group_header(builder)
125
+ builder.GrpHdr do
126
+ builder.MsgId(message_identification)
127
+ builder.CreDtTm(creation_date_time)
128
+ builder.NbOfTxs(transactions.length)
129
+ builder.CtrlSum('%.2f' % amount_total)
130
+ builder.InitgPty do
131
+ builder.Nm(account.name)
132
+ builder.Id do
133
+ builder.OrgId do
134
+ builder.Othr do
135
+ builder.Id(account.creditor_identifier)
136
+ end
137
+ end
138
+ end if account.respond_to? :creditor_identifier
139
+ end
140
+ end
141
+ end
142
+
143
+ # Unique and consecutive identifier (used for the <PmntInf> blocks)
144
+ def payment_information_identification(group)
145
+ "#{message_identification}/#{grouped_transactions.keys.index(group)+1}"
146
+ end
147
+
148
+ # Returns a key to determine the group to which the transaction belongs
149
+ def transaction_group(transaction)
150
+ transaction
151
+ end
152
+
153
+ def validate_final_document!(document, schema_name)
154
+ xsd = Nokogiri::XML::Schema(
155
+ File.read(
156
+ File.expand_path("../../../lib/schema/#{schema_name}.xsd", __FILE__)
157
+ )
158
+ )
159
+ errors = xsd.validate(document).map { |error| error.message }
160
+ raise SPS::Error.new("Incompatible with schema #{schema_name}: #{errors.join(', ')}") if errors.any?
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,140 @@
1
+ # encoding: utf-8
2
+
3
+ module SPS
4
+ class CreditTransfer < Message
5
+ self.account_class = DebtorAccount
6
+ self.transaction_class = CreditTransferTransaction
7
+ self.xml_main_tag = 'CstmrCdtTrfInitn'
8
+ self.known_schemas = [
9
+ PAIN_001_001_03_CH_02
10
+ ]
11
+
12
+ private
13
+ # Find groups of transactions which share the same values of some attributes
14
+ def transaction_group(transaction)
15
+ {
16
+ requested_date: transaction.requested_date,
17
+ batch_booking: transaction.batch_booking,
18
+ service_level: transaction.service_level,
19
+ category_purpose: transaction.category_purpose
20
+ }
21
+ end
22
+
23
+ def build_payment_informations(builder)
24
+ # Build a PmtInf block for every group of transactions
25
+ grouped_transactions.each do |group, transactions|
26
+ # All transactions with the same requested_date are placed into the same PmtInf block
27
+ builder.PmtInf do
28
+ builder.PmtInfId(payment_information_identification(group))
29
+ builder.PmtMtd('TRF')
30
+ builder.BtchBookg(group[:batch_booking])
31
+ builder.NbOfTxs(transactions.length)
32
+ builder.CtrlSum('%.2f' % amount_total(transactions))
33
+ builder.PmtTpInf do
34
+ if group[:service_level]
35
+ builder.SvcLvl do
36
+ builder.Cd(group[:service_level])
37
+ end
38
+ end
39
+ if group[:category_purpose]
40
+ builder.CtgyPurp do
41
+ builder.Cd(group[:category_purpose])
42
+ end
43
+ end
44
+ end
45
+ builder.ReqdExctnDt(group[:requested_date].iso8601)
46
+ builder.Dbtr do
47
+ builder.Nm(account.name)
48
+ end
49
+ builder.DbtrAcct do
50
+ builder.Id do
51
+ builder.IBAN(account.iban)
52
+ end
53
+ end
54
+ builder.DbtrAgt do
55
+ builder.FinInstnId do
56
+ builder.BIC(account.bic)
57
+ end
58
+ end
59
+ builder.ChrgBr('SLEV')
60
+
61
+ transactions.each do |transaction|
62
+ build_transaction(builder, transaction)
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ def build_transaction(builder, transaction)
69
+ builder.CdtTrfTxInf do
70
+ builder.PmtId do
71
+ if transaction.instruction.present?
72
+ builder.InstrId(transaction.instruction)
73
+ end
74
+ builder.EndToEndId(transaction.reference)
75
+ end
76
+ builder.Amt do
77
+ builder.InstdAmt('%.2f' % transaction.amount, Ccy: transaction.currency)
78
+ end
79
+ if transaction.bic
80
+ builder.CdtrAgt do
81
+ builder.FinInstnId do
82
+ builder.BIC(transaction.bic)
83
+ end
84
+ end
85
+ end
86
+ builder.Cdtr do
87
+ builder.Nm(transaction.name)
88
+ if transaction.creditor_address
89
+ builder.PstlAdr do
90
+ # Only set the fields that are actually provided.
91
+ # StrtNm, BldgNb, PstCd, TwnNm provide a structured address
92
+ # separated into its individual fields.
93
+ # AdrLine provides the address in free format text.
94
+ # Both are currently allowed and the actual preference depends on the bank.
95
+ # Also the fields that are required legally may vary depending on the country
96
+ # or change over time.
97
+ if transaction.creditor_address.street_name
98
+ builder.StrtNm transaction.creditor_address.street_name
99
+ end
100
+
101
+ if transaction.creditor_address.building_number
102
+ builder.BldgNb transaction.creditor_address.building_number
103
+ end
104
+
105
+ if transaction.creditor_address.post_code
106
+ builder.PstCd transaction.creditor_address.post_code
107
+ end
108
+
109
+ if transaction.creditor_address.town_name
110
+ builder.TwnNm transaction.creditor_address.town_name
111
+ end
112
+
113
+ if transaction.creditor_address.country_code
114
+ builder.Ctry transaction.creditor_address.country_code
115
+ end
116
+
117
+ if transaction.creditor_address.address_line1
118
+ builder.AdrLine transaction.creditor_address.address_line1
119
+ end
120
+
121
+ if transaction.creditor_address.address_line2
122
+ builder.AdrLine transaction.creditor_address.address_line2
123
+ end
124
+ end
125
+ end
126
+ end
127
+ builder.CdtrAcct do
128
+ builder.Id do
129
+ builder.IBAN(transaction.iban)
130
+ end
131
+ end
132
+ if transaction.remittance_information
133
+ builder.RmtInf do
134
+ builder.Ustrd(transaction.remittance_information)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end