sepa_king_codeur 0.12.1

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