sepa_king_codeur 0.12.1

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