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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +23 -0
  3. data/README.md +117 -0
  4. data/lib/schema/pain.001.001.03.ch.02.xsd +1212 -0
  5. data/lib/schema/pain.001.001.03.xsd +921 -0
  6. data/lib/schema/pain.001.001.09.xsd +1114 -0
  7. data/lib/schema/pain.001.001.13.xsd +1251 -0
  8. data/lib/schema/pain.001.002.03.xsd +450 -0
  9. data/lib/schema/pain.001.003.03.xsd +474 -0
  10. data/lib/schema/pain.008.001.02.xsd +879 -0
  11. data/lib/schema/pain.008.001.08.xsd +1106 -0
  12. data/lib/schema/pain.008.001.12.xsd +1135 -0
  13. data/lib/schema/pain.008.002.02.xsd +597 -0
  14. data/lib/schema/pain.008.003.02.xsd +614 -0
  15. data/lib/sepa_rator/account/address.rb +71 -0
  16. data/lib/sepa_rator/account/contact_details.rb +70 -0
  17. data/lib/sepa_rator/account/creditor_account.rb +16 -0
  18. data/lib/sepa_rator/account/creditor_address.rb +5 -0
  19. data/lib/sepa_rator/account/debtor_account.rb +20 -0
  20. data/lib/sepa_rator/account/debtor_address.rb +5 -0
  21. data/lib/sepa_rator/account.rb +64 -0
  22. data/lib/sepa_rator/concerns/attribute_initializer.rb +40 -0
  23. data/lib/sepa_rator/concerns/regulatory_reporting_validator.rb +111 -0
  24. data/lib/sepa_rator/concerns/schema_validation.rb +41 -0
  25. data/lib/sepa_rator/concerns/xml_builder.rb +111 -0
  26. data/lib/sepa_rator/converter.rb +46 -0
  27. data/lib/sepa_rator/error.rb +15 -0
  28. data/lib/sepa_rator/message/credit_transfer.rb +221 -0
  29. data/lib/sepa_rator/message/direct_debit.rb +153 -0
  30. data/lib/sepa_rator/message.rb +284 -0
  31. data/lib/sepa_rator/transaction/credit_transfer_transaction.rb +178 -0
  32. data/lib/sepa_rator/transaction/direct_debit_transaction.rb +104 -0
  33. data/lib/sepa_rator/transaction.rb +114 -0
  34. data/lib/sepa_rator/validator.rb +99 -0
  35. data/lib/sepa_rator/version.rb +5 -0
  36. data/lib/sepa_rator.rb +27 -0
  37. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SEPA
4
+ class CreditorAddress < Address; end
5
+ 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SEPA
4
+ class DebtorAddress < Address; end
5
+ 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