sepa_rator 0.15.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +23 -0
- data/README.md +117 -0
- data/lib/schema/pain.001.001.03.ch.02.xsd +1212 -0
- data/lib/schema/pain.001.001.03.xsd +921 -0
- data/lib/schema/pain.001.001.09.xsd +1114 -0
- data/lib/schema/pain.001.001.13.xsd +1251 -0
- data/lib/schema/pain.001.002.03.xsd +450 -0
- data/lib/schema/pain.001.003.03.xsd +474 -0
- data/lib/schema/pain.008.001.02.xsd +879 -0
- data/lib/schema/pain.008.001.08.xsd +1106 -0
- data/lib/schema/pain.008.001.12.xsd +1135 -0
- data/lib/schema/pain.008.002.02.xsd +597 -0
- data/lib/schema/pain.008.003.02.xsd +614 -0
- data/lib/sepa_rator/account/address.rb +71 -0
- data/lib/sepa_rator/account/contact_details.rb +70 -0
- data/lib/sepa_rator/account/creditor_account.rb +16 -0
- data/lib/sepa_rator/account/creditor_address.rb +5 -0
- data/lib/sepa_rator/account/debtor_account.rb +20 -0
- data/lib/sepa_rator/account/debtor_address.rb +5 -0
- data/lib/sepa_rator/account.rb +64 -0
- data/lib/sepa_rator/concerns/attribute_initializer.rb +40 -0
- data/lib/sepa_rator/concerns/regulatory_reporting_validator.rb +111 -0
- data/lib/sepa_rator/concerns/schema_validation.rb +41 -0
- data/lib/sepa_rator/concerns/xml_builder.rb +111 -0
- data/lib/sepa_rator/converter.rb +46 -0
- data/lib/sepa_rator/error.rb +15 -0
- data/lib/sepa_rator/message/credit_transfer.rb +221 -0
- data/lib/sepa_rator/message/direct_debit.rb +153 -0
- data/lib/sepa_rator/message.rb +284 -0
- data/lib/sepa_rator/transaction/credit_transfer_transaction.rb +178 -0
- data/lib/sepa_rator/transaction/direct_debit_transaction.rb +104 -0
- data/lib/sepa_rator/transaction.rb +114 -0
- data/lib/sepa_rator/validator.rb +99 -0
- data/lib/sepa_rator/version.rb +5 -0
- data/lib/sepa_rator.rb +27 -0
- metadata +128 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
class Address
|
|
5
|
+
include ActiveModel::Validations
|
|
6
|
+
include AttributeInitializer
|
|
7
|
+
extend Converter
|
|
8
|
+
|
|
9
|
+
# PostalAddress6 fields (all schemas)
|
|
10
|
+
attr_accessor :street_name,
|
|
11
|
+
:building_number,
|
|
12
|
+
:post_code,
|
|
13
|
+
:town_name,
|
|
14
|
+
:country_code,
|
|
15
|
+
:address_line1,
|
|
16
|
+
:address_line2,
|
|
17
|
+
# PostalAddress24 fields (.09/.08 and above)
|
|
18
|
+
:department,
|
|
19
|
+
:sub_department,
|
|
20
|
+
:building_name,
|
|
21
|
+
:floor,
|
|
22
|
+
:post_box,
|
|
23
|
+
:room,
|
|
24
|
+
:town_location_name,
|
|
25
|
+
:district_name,
|
|
26
|
+
:country_sub_division,
|
|
27
|
+
# PostalAddress27 fields (.13/.12 only)
|
|
28
|
+
:care_of,
|
|
29
|
+
:unit_number
|
|
30
|
+
|
|
31
|
+
convert :street_name, to: :text
|
|
32
|
+
convert :building_number, to: :text
|
|
33
|
+
convert :post_code, to: :text
|
|
34
|
+
convert :town_name, to: :text
|
|
35
|
+
convert :country_code, to: :text
|
|
36
|
+
convert :address_line1, to: :text
|
|
37
|
+
convert :address_line2, to: :text
|
|
38
|
+
convert :department, to: :text
|
|
39
|
+
convert :sub_department, to: :text
|
|
40
|
+
convert :building_name, to: :text
|
|
41
|
+
convert :floor, to: :text
|
|
42
|
+
convert :post_box, to: :text
|
|
43
|
+
convert :room, to: :text
|
|
44
|
+
convert :town_location_name, to: :text
|
|
45
|
+
convert :district_name, to: :text
|
|
46
|
+
convert :country_sub_division, to: :text
|
|
47
|
+
convert :care_of, to: :text
|
|
48
|
+
convert :unit_number, to: :text
|
|
49
|
+
|
|
50
|
+
# Max lengths use the most permissive schema (PostalAddress27).
|
|
51
|
+
# Stricter per-schema limits are enforced by XSD validation in validate_final_document!.
|
|
52
|
+
validates_length_of :street_name, maximum: 140
|
|
53
|
+
validates_length_of :building_number, maximum: 16
|
|
54
|
+
validates_length_of :post_code, maximum: 16
|
|
55
|
+
validates_length_of :town_name, maximum: 140
|
|
56
|
+
validates_length_of :country_code, is: 2
|
|
57
|
+
validates_length_of :address_line1, maximum: 70
|
|
58
|
+
validates_length_of :address_line2, maximum: 70
|
|
59
|
+
validates_length_of :department, maximum: 70, allow_nil: true
|
|
60
|
+
validates_length_of :sub_department, maximum: 70, allow_nil: true
|
|
61
|
+
validates_length_of :building_name, maximum: 140, allow_nil: true
|
|
62
|
+
validates_length_of :floor, maximum: 70, allow_nil: true
|
|
63
|
+
validates_length_of :post_box, maximum: 16, allow_nil: true
|
|
64
|
+
validates_length_of :room, maximum: 70, allow_nil: true
|
|
65
|
+
validates_length_of :town_location_name, maximum: 140, allow_nil: true
|
|
66
|
+
validates_length_of :district_name, maximum: 140, allow_nil: true
|
|
67
|
+
validates_length_of :country_sub_division, maximum: 35, allow_nil: true
|
|
68
|
+
validates_length_of :care_of, maximum: 140, allow_nil: true
|
|
69
|
+
validates_length_of :unit_number, maximum: 16, allow_nil: true
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
class ContactDetails
|
|
5
|
+
include ActiveModel::Validations
|
|
6
|
+
include AttributeInitializer
|
|
7
|
+
extend Converter
|
|
8
|
+
|
|
9
|
+
NAME_PREFIXES = %w[DOCT MADM MISS MIST MIKS].freeze
|
|
10
|
+
PREFERRED_METHODS = %w[LETT MAIL PHON FAXX CELL ONLI].freeze
|
|
11
|
+
|
|
12
|
+
# Common fields (ContactDetails2, all schemas)
|
|
13
|
+
attr_accessor :name_prefix,
|
|
14
|
+
:name,
|
|
15
|
+
:phone_number,
|
|
16
|
+
:mobile_number,
|
|
17
|
+
:fax_number,
|
|
18
|
+
:email_address,
|
|
19
|
+
# Contact4 fields (v09+, XSD rejects for v03)
|
|
20
|
+
:email_purpose,
|
|
21
|
+
:job_title,
|
|
22
|
+
:responsibility,
|
|
23
|
+
:department,
|
|
24
|
+
# Contact13 fields (v13 only, XSD rejects for v03/v09)
|
|
25
|
+
:url_address,
|
|
26
|
+
# Complex/enum fields handled separately in XML builder
|
|
27
|
+
:other_contacts, # Array<{channel_type:, id:}> (OtherContact1, v09/v13)
|
|
28
|
+
:preferred_method # PreferredContactMethod1Code/2Code (v09/v13)
|
|
29
|
+
|
|
30
|
+
convert :name, :phone_number, :mobile_number, :fax_number,
|
|
31
|
+
:email_purpose, :job_title, :responsibility, :department, to: :text
|
|
32
|
+
|
|
33
|
+
# Superset lengths (most permissive schema).
|
|
34
|
+
# Stricter per-schema limits are enforced by XSD validation in validate_final_document!.
|
|
35
|
+
validates_inclusion_of :name_prefix, in: NAME_PREFIXES, allow_nil: true
|
|
36
|
+
validates_length_of :name, maximum: 140, allow_nil: true
|
|
37
|
+
validates_length_of :phone_number, maximum: 30, allow_nil: true
|
|
38
|
+
validates_length_of :mobile_number, maximum: 30, allow_nil: true
|
|
39
|
+
validates_length_of :fax_number, maximum: 30, allow_nil: true
|
|
40
|
+
validates_length_of :url_address, maximum: 2048, allow_nil: true
|
|
41
|
+
validates_length_of :email_address, maximum: 2048, allow_nil: true
|
|
42
|
+
validates_length_of :email_purpose, maximum: 35, allow_nil: true
|
|
43
|
+
validates_length_of :job_title, maximum: 35, allow_nil: true
|
|
44
|
+
validates_length_of :responsibility, maximum: 35, allow_nil: true
|
|
45
|
+
validates_length_of :department, maximum: 70, allow_nil: true
|
|
46
|
+
validates_inclusion_of :preferred_method, in: PREFERRED_METHODS, allow_nil: true
|
|
47
|
+
|
|
48
|
+
validate :validate_other_contacts
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def validate_other_contacts
|
|
53
|
+
return unless other_contacts
|
|
54
|
+
|
|
55
|
+
unless other_contacts.is_a?(Array)
|
|
56
|
+
errors.add(:other_contacts, 'must be an Array')
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
other_contacts.each_with_index do |contact, i|
|
|
61
|
+
unless contact.is_a?(Hash) && contact[:channel_type]
|
|
62
|
+
errors.add(:other_contacts, "entry #{i} must have :channel_type")
|
|
63
|
+
next
|
|
64
|
+
end
|
|
65
|
+
errors.add(:other_contacts, "entry #{i} channel_type exceeds 4 characters") if contact[:channel_type].to_s.length > 4
|
|
66
|
+
errors.add(:other_contacts, "entry #{i} id exceeds 128 characters") if contact[:id] && contact[:id].to_s.length > 128
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
class CreditorAccount < Account
|
|
5
|
+
attr_accessor :creditor_identifier, :initiating_party_lei, :initiating_party_bic
|
|
6
|
+
|
|
7
|
+
validates_with CreditorIdentifierValidator, message: 'is invalid'
|
|
8
|
+
validates_with LEIValidator, field_name: :initiating_party_lei, message: 'is invalid'
|
|
9
|
+
validates_with BICValidator, field_name: :initiating_party_bic, message: 'is invalid'
|
|
10
|
+
|
|
11
|
+
def initiating_party_id(builder, schema_name)
|
|
12
|
+
build_organisation_id(builder, creditor_identifier, schema_name,
|
|
13
|
+
lei: initiating_party_lei, org_bic: initiating_party_bic)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
class DebtorAccount < Account
|
|
5
|
+
attr_accessor :initiating_party_identifier, :initiating_party_lei, :initiating_party_bic
|
|
6
|
+
|
|
7
|
+
convert :initiating_party_identifier, to: :text
|
|
8
|
+
# Max256Text (v13 GenericOrganisationIdentification3); stricter v03/v09 limits enforced by XSD
|
|
9
|
+
validates_length_of :initiating_party_identifier, within: 1..256, allow_nil: true
|
|
10
|
+
validates_with LEIValidator, field_name: :initiating_party_lei, message: 'is invalid'
|
|
11
|
+
validates_with BICValidator, field_name: :initiating_party_bic, message: 'is invalid'
|
|
12
|
+
|
|
13
|
+
def initiating_party_id(builder, schema_name)
|
|
14
|
+
return unless initiating_party_identifier || initiating_party_bic || initiating_party_lei
|
|
15
|
+
|
|
16
|
+
build_organisation_id(builder, initiating_party_identifier, schema_name,
|
|
17
|
+
lei: initiating_party_lei, org_bic: initiating_party_bic)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
class Account
|
|
5
|
+
include ActiveModel::Validations
|
|
6
|
+
include AttributeInitializer
|
|
7
|
+
extend Converter
|
|
8
|
+
|
|
9
|
+
attr_accessor :name, :iban, :bic, :address, :agent_lei, :contact_details
|
|
10
|
+
|
|
11
|
+
convert :name, to: :text
|
|
12
|
+
|
|
13
|
+
validates_length_of :name, within: 1..70
|
|
14
|
+
validates_with BICValidator, IBANValidator, message: 'is invalid'
|
|
15
|
+
validates_with LEIValidator, field_name: :agent_lei, message: 'is invalid'
|
|
16
|
+
|
|
17
|
+
validate do |record|
|
|
18
|
+
next unless record.address
|
|
19
|
+
|
|
20
|
+
unless record.address.valid?
|
|
21
|
+
record.address.errors.each do |error|
|
|
22
|
+
record.errors.add(:address, error.full_message)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
validate do |record|
|
|
28
|
+
next unless record.contact_details
|
|
29
|
+
|
|
30
|
+
unless record.contact_details.valid?
|
|
31
|
+
record.contact_details.errors.each do |error|
|
|
32
|
+
record.errors.add(:contact_details, error.full_message)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initiating_party_id(builder, schema_name); end
|
|
38
|
+
|
|
39
|
+
protected
|
|
40
|
+
|
|
41
|
+
# Builds Id > OrgId block. XSD sequence: BICOrBEI/AnyBIC → LEI → Othr
|
|
42
|
+
def build_organisation_id(builder, identifier, schema_name, **options)
|
|
43
|
+
builder.Id do
|
|
44
|
+
builder.OrgId do
|
|
45
|
+
build_org_bic_and_lei(builder, schema_name, options)
|
|
46
|
+
if identifier
|
|
47
|
+
builder.Othr do
|
|
48
|
+
builder.Id(identifier)
|
|
49
|
+
builder.SchmeNm { builder.Prtry(options[:scheme]) } if options[:scheme]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_org_bic_and_lei(builder, schema_name, options)
|
|
57
|
+
if options[:org_bic]
|
|
58
|
+
bic_tag = SCHEMA_FEATURES[schema_name][:org_bic_tag]
|
|
59
|
+
builder.__send__(bic_tag, options[:org_bic])
|
|
60
|
+
end
|
|
61
|
+
builder.LEI(options[:lei]) if options[:lei] && LEI_SCHEMAS.include?(schema_name)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
module AttributeInitializer
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.include ActiveModel::AttributeAssignment
|
|
7
|
+
base.extend ClassMethods
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
def permitted_attributes
|
|
12
|
+
@permitted_attributes ||= begin
|
|
13
|
+
own_setters = instance_methods(false)
|
|
14
|
+
.select { |m| m.to_s.end_with?('=') }
|
|
15
|
+
.to_set { |m| m.to_s.chomp('=') }
|
|
16
|
+
|
|
17
|
+
if superclass.respond_to?(:permitted_attributes)
|
|
18
|
+
own_setters | superclass.permitted_attributes
|
|
19
|
+
else
|
|
20
|
+
own_setters
|
|
21
|
+
end
|
|
22
|
+
end.freeze
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(attributes = {})
|
|
27
|
+
assign_attributes(attributes)
|
|
28
|
+
rescue ActiveModel::UnknownAttributeError => e
|
|
29
|
+
raise ArgumentError, "Unknown attribute: #{e.attribute}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def _assign_attribute(key, value)
|
|
35
|
+
raise ArgumentError, "Unknown attribute: #{key}" unless self.class.permitted_attributes.include?(key.to_s)
|
|
36
|
+
|
|
37
|
+
public_send("#{key}=", value)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
# Extracted validation logic for regulatory reporting fields on CreditTransferTransaction.
|
|
5
|
+
# Keeps the transaction class under the Metrics/ClassLength limit.
|
|
6
|
+
module RegulatoryReportingValidator
|
|
7
|
+
REGULATORY_INDICATORS = %w[CRED DEBT BOTH].freeze
|
|
8
|
+
COUNTRY_CODE_REGEX = /\A[A-Z]{2}\z/
|
|
9
|
+
CURRENCY_CODE_REGEX = /\A[A-Z]{3}\z/
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def validate_regulatory_reportings
|
|
14
|
+
return unless regulatory_reportings
|
|
15
|
+
|
|
16
|
+
unless regulatory_reportings.is_a?(Array)
|
|
17
|
+
errors.add(:regulatory_reportings, 'must be an Array')
|
|
18
|
+
return
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
errors.add(:regulatory_reportings, 'maximum 10 entries') if regulatory_reportings.length > 10
|
|
22
|
+
|
|
23
|
+
regulatory_reportings.each_with_index do |reporting, i|
|
|
24
|
+
unless reporting.is_a?(Hash)
|
|
25
|
+
errors.add(:regulatory_reportings, "entry #{i} must be a Hash")
|
|
26
|
+
next
|
|
27
|
+
end
|
|
28
|
+
if reporting[:indicator] && !REGULATORY_INDICATORS.include?(reporting[:indicator])
|
|
29
|
+
errors.add(:regulatory_reportings, "entry #{i} indicator must be one of #{REGULATORY_INDICATORS.join(', ')}")
|
|
30
|
+
end
|
|
31
|
+
validate_regulatory_reporting_details(reporting, i)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_regulatory_reporting_details(reporting, entry_index)
|
|
36
|
+
validate_regulatory_authority(reporting[:authority], entry_index)
|
|
37
|
+
return unless reporting[:details]
|
|
38
|
+
|
|
39
|
+
unless reporting[:details].is_a?(Array)
|
|
40
|
+
errors.add(:regulatory_reportings, "entry #{entry_index} details must be an Array")
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
reporting[:details].each_with_index do |detail, j|
|
|
45
|
+
unless detail.is_a?(Hash)
|
|
46
|
+
errors.add(:regulatory_reportings, "entry #{entry_index} detail #{j} must be a Hash")
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
validate_regulatory_detail(detail, entry_index, j)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_regulatory_authority(authority, entry_index)
|
|
54
|
+
return unless authority
|
|
55
|
+
|
|
56
|
+
unless authority.is_a?(Hash)
|
|
57
|
+
errors.add(:regulatory_reportings, "entry #{entry_index} authority must be a Hash")
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
validate_authority_name(authority[:name], entry_index)
|
|
61
|
+
validate_country_code(authority[:country], "entry #{entry_index} authority country")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_authority_name(name, entry_index)
|
|
65
|
+
return unless name && name.to_s.length > 140
|
|
66
|
+
|
|
67
|
+
errors.add(:regulatory_reportings, "entry #{entry_index} authority name exceeds 140 characters")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_regulatory_detail(detail, entry_idx, detail_idx)
|
|
71
|
+
prefix = "entry #{entry_idx} detail #{detail_idx}"
|
|
72
|
+
validate_regulatory_detail_text_fields(detail, prefix)
|
|
73
|
+
validate_regulatory_detail_typed_fields(detail, prefix)
|
|
74
|
+
validate_regulatory_detail_amount(detail[:amount], prefix)
|
|
75
|
+
Array(detail[:information]).each_with_index do |inf, idx|
|
|
76
|
+
errors.add(:regulatory_reportings, "#{prefix} information #{idx} exceeds 35 characters") if inf.to_s.length > 35
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_regulatory_detail_text_fields(detail, prefix)
|
|
81
|
+
errors.add(:regulatory_reportings, "#{prefix} code too long") if detail[:code] && detail[:code].to_s.length > 10
|
|
82
|
+
errors.add(:regulatory_reportings, "#{prefix} type too long") if detail[:type] && detail[:type].to_s.length > 35
|
|
83
|
+
errors.add(:regulatory_reportings, "#{prefix} type_proprietary too long") if detail[:type_proprietary] && detail[:type_proprietary].to_s.length > 35
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_regulatory_detail_typed_fields(detail, prefix)
|
|
87
|
+
errors.add(:regulatory_reportings, "#{prefix} type and type_proprietary are mutually exclusive") if detail[:type] && detail[:type_proprietary]
|
|
88
|
+
errors.add(:regulatory_reportings, "#{prefix} date must be a Date") if detail[:date] && !detail[:date].is_a?(Date)
|
|
89
|
+
validate_country_code(detail[:country], "#{prefix} country")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_regulatory_detail_amount(amount, prefix)
|
|
93
|
+
return unless amount
|
|
94
|
+
|
|
95
|
+
unless amount.is_a?(Hash) && amount[:value] && amount[:currency]
|
|
96
|
+
errors.add(:regulatory_reportings, "#{prefix} amount must have :value and :currency")
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
unless amount[:value].is_a?(Integer) || amount[:value].is_a?(Float) || amount[:value].is_a?(BigDecimal)
|
|
100
|
+
errors.add(:regulatory_reportings, "#{prefix} amount value must be numeric")
|
|
101
|
+
end
|
|
102
|
+
errors.add(:regulatory_reportings, "#{prefix} amount currency invalid") unless amount[:currency].to_s.match?(CURRENCY_CODE_REGEX)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_country_code(value, field_label)
|
|
106
|
+
return unless value && !value.to_s.match?(COUNTRY_CODE_REGEX)
|
|
107
|
+
|
|
108
|
+
errors.add(:regulatory_reportings, "#{field_label} must be a 2-letter code")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
module SchemaValidation
|
|
5
|
+
SCHEMA_DIR = File.expand_path('../../schema', __dir__).freeze
|
|
6
|
+
SCHEMA_CACHE_MUTEX = Mutex.new
|
|
7
|
+
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend ClassMethods
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def schema_cache
|
|
14
|
+
@schema_cache ||= {}
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def validate_final_document!(document, schema_name)
|
|
21
|
+
raise ArgumentError, "Unknown schema: #{schema_name}" unless SCHEMA_FEATURES.key?(schema_name)
|
|
22
|
+
|
|
23
|
+
xsd = self.class.schema_cache[schema_name]
|
|
24
|
+
unless xsd
|
|
25
|
+
SCHEMA_CACHE_MUTEX.synchronize do
|
|
26
|
+
xsd = self.class.schema_cache[schema_name] ||=
|
|
27
|
+
Nokogiri::XML::Schema(File.read("#{SCHEMA_DIR}/#{schema_name}.xsd"))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
validation_errors = xsd.validate(document)
|
|
32
|
+
return if validation_errors.empty?
|
|
33
|
+
|
|
34
|
+
sanitized = validation_errors.map { |e| e.message.gsub(/'[^']{20,}'/, "'[REDACTED]'") }
|
|
35
|
+
raise SEPA::SchemaValidationError.new(
|
|
36
|
+
"Incompatible with schema #{schema_name}: #{sanitized.join(', ')}",
|
|
37
|
+
validation_errors.map(&:message)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
module XmlBuilder
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def build_postal_address(builder, address)
|
|
8
|
+
builder.PstlAdr do
|
|
9
|
+
POSTAL_ADDRESS_FIELDS.each do |xml_tag, attr|
|
|
10
|
+
value = address.public_send(attr)
|
|
11
|
+
builder.__send__(xml_tag, value) if value
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def build_agent_bic(builder, bic, schema_name, fallback: true, lei: nil)
|
|
17
|
+
lei_emitted = lei && LEI_SCHEMAS.include?(schema_name)
|
|
18
|
+
|
|
19
|
+
builder.FinInstnId do
|
|
20
|
+
# XSD sequence: BICFI/BIC → ClrSysMmbId → LEI → Nm → PstlAdr → Othr
|
|
21
|
+
builder.__send__(schema_features(schema_name)[:bic_tag], bic) if bic
|
|
22
|
+
builder.LEI(lei) if lei_emitted
|
|
23
|
+
if !bic && !lei_emitted && fallback
|
|
24
|
+
builder.Othr do
|
|
25
|
+
builder.Id('NOTPROVIDED')
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_remittance_information(builder, transaction)
|
|
32
|
+
has_structured = transaction.structured_remittance_information || transaction.additional_remittance_information
|
|
33
|
+
return unless transaction.remittance_information || has_structured
|
|
34
|
+
|
|
35
|
+
builder.RmtInf do
|
|
36
|
+
if has_structured
|
|
37
|
+
builder.Strd do
|
|
38
|
+
build_creditor_reference_information(builder, transaction) if transaction.structured_remittance_information
|
|
39
|
+
Array(transaction.additional_remittance_information).each { |info| builder.AddtlRmtInf(info) }
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
builder.Ustrd(transaction.remittance_information)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_creditor_reference_information(builder, transaction)
|
|
48
|
+
builder.CdtrRefInf do
|
|
49
|
+
ref_type = transaction.structured_remittance_reference_type || 'SCOR'
|
|
50
|
+
builder.Tp do
|
|
51
|
+
builder.CdOrPrtry { builder.Cd(ref_type) }
|
|
52
|
+
builder.Issr(transaction.structured_remittance_issuer) if transaction.structured_remittance_issuer
|
|
53
|
+
end
|
|
54
|
+
builder.Ref(transaction.structured_remittance_information)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_contact_details(builder, contact_details)
|
|
59
|
+
return unless contact_details
|
|
60
|
+
|
|
61
|
+
builder.CtctDtls do
|
|
62
|
+
CONTACT_DETAILS_FIELDS.each do |xml_tag, attr|
|
|
63
|
+
value = contact_details.public_send(attr)
|
|
64
|
+
builder.__send__(xml_tag, value) if value
|
|
65
|
+
end
|
|
66
|
+
contact_details.other_contacts&.each do |contact|
|
|
67
|
+
builder.Othr do
|
|
68
|
+
builder.ChanlTp(contact[:channel_type])
|
|
69
|
+
builder.Id(contact[:id]) if contact[:id]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
builder.PrefrdMtd(contact_details.preferred_method) if contact_details.preferred_method
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def build_ultimate_party(builder, tag, name, contact_details: nil)
|
|
77
|
+
return unless name
|
|
78
|
+
|
|
79
|
+
builder.__send__(tag) do
|
|
80
|
+
builder.Nm(name)
|
|
81
|
+
build_contact_details(builder, contact_details)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_purpose(builder, purpose_code)
|
|
86
|
+
return unless purpose_code
|
|
87
|
+
|
|
88
|
+
builder.Purp { builder.Cd(purpose_code) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_payment_identification(builder, transaction)
|
|
92
|
+
builder.PmtId do
|
|
93
|
+
builder.InstrId(transaction.instruction) if transaction.instruction && !transaction.instruction.empty?
|
|
94
|
+
builder.EndToEndId(transaction.reference)
|
|
95
|
+
builder.UETR(transaction.uetr) if transaction.uetr && !transaction.uetr.empty?
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_iban_account(builder, tag, iban)
|
|
100
|
+
builder.__send__(tag) do
|
|
101
|
+
builder.Id do
|
|
102
|
+
builder.IBAN(iban)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def format_amount(value)
|
|
108
|
+
format('%.2f', value)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
module Converter
|
|
5
|
+
def convert(*attributes, options)
|
|
6
|
+
include InstanceMethods
|
|
7
|
+
|
|
8
|
+
method_name = "convert_#{options[:to]}"
|
|
9
|
+
raise ArgumentError, "Converter '#{options[:to]}' does not exist!" unless InstanceMethods.method_defined?(method_name)
|
|
10
|
+
|
|
11
|
+
attributes.each do |attribute|
|
|
12
|
+
define_method "#{attribute}=" do |value|
|
|
13
|
+
instance_variable_set("@#{attribute}", public_send(method_name, value))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module InstanceMethods
|
|
19
|
+
def convert_text(value)
|
|
20
|
+
return unless value
|
|
21
|
+
|
|
22
|
+
# Replace special characters (EPC Best Practices, Chapter 6.2)
|
|
23
|
+
# http://www.europeanpaymentscouncil.eu/index.cfm/knowledge-bank/epc-documents/sepa-requirements-for-an-extended-character-set-unicode-subset-best-practices/
|
|
24
|
+
value.to_s
|
|
25
|
+
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
|
|
26
|
+
.tr('€', 'E')
|
|
27
|
+
.gsub('@', '(at)')
|
|
28
|
+
.tr('_', '-')
|
|
29
|
+
.tr('&', '+')
|
|
30
|
+
.gsub(/\n+/, ' ')
|
|
31
|
+
.gsub(%r{[^a-zA-Z0-9\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u024F ':?,\-(+.)/]}, '')
|
|
32
|
+
.strip
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def convert_decimal(value)
|
|
36
|
+
return unless value
|
|
37
|
+
|
|
38
|
+
value = BigDecimal(value.to_s, exception: false)
|
|
39
|
+
|
|
40
|
+
return unless value&.finite? && value.positive?
|
|
41
|
+
|
|
42
|
+
value.round(2)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SEPA
|
|
4
|
+
class Error < RuntimeError; end
|
|
5
|
+
class ValidationError < Error; end
|
|
6
|
+
|
|
7
|
+
class SchemaValidationError < Error
|
|
8
|
+
attr_reader :validation_errors
|
|
9
|
+
|
|
10
|
+
def initialize(message, validation_errors = [])
|
|
11
|
+
@validation_errors = validation_errors
|
|
12
|
+
super(message)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|