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