brot 0.1.1 → 0.3.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.
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brot
4
+ # Payment batch document that serializes into a supported pain.001 version.
5
+ class Document
6
+ attr_reader :batch_booking,
7
+ :created_at,
8
+ :debtor_account,
9
+ :debtor_name,
10
+ :initiating_party_name,
11
+ :message_id,
12
+ :payment_information_id,
13
+ :requested_execution_date,
14
+ :transfers,
15
+ :version
16
+
17
+ # @param attributes [Hash]
18
+ # @option attributes [PainVersion, Symbol, String] :version
19
+ # Defaults to {PainVersion::PAIN_001_001_03}.
20
+ def initialize(**attributes)
21
+ @version = PainVersion.fetch(attributes.fetch(:version, PainVersion.default))
22
+ assign_identifiers(attributes)
23
+ assign_party_details(attributes)
24
+ assign_payment_schedule(attributes)
25
+ end
26
+
27
+ # Returns the sum of all transfer amounts.
28
+ #
29
+ # @return [BigDecimal]
30
+ def control_sum
31
+ transfers.sum(BigDecimal('0'), &:amount)
32
+ end
33
+
34
+ # Returns the number of transfers in the document.
35
+ #
36
+ # @return [Integer]
37
+ def number_of_transactions
38
+ transfers.length
39
+ end
40
+
41
+ # Serializes the document into the configured pain.001 XML version.
42
+ #
43
+ # @param pretty [Boolean]
44
+ # @return [String]
45
+ def to_xml(pretty: true)
46
+ serializer_config.serializer_class.new(self).to_xml(pretty: pretty)
47
+ end
48
+
49
+ # Validates the generated XML against the configured bundled schema by default.
50
+ #
51
+ # @param xsd_path [String, nil]
52
+ # @return [Schema::Result]
53
+ def validate(xsd_path: nil)
54
+ Schema.validate(to_xml(pretty: false), version: version, xsd_path: xsd_path)
55
+ end
56
+
57
+ # Validates the generated XML and raises when the schema check fails.
58
+ #
59
+ # @param xsd_path [String, nil]
60
+ # @return [Schema::Result]
61
+ def validate!(xsd_path: nil)
62
+ Schema.validate!(to_xml(pretty: false), version: version, xsd_path: xsd_path)
63
+ end
64
+
65
+ private
66
+
67
+ def serializer_config
68
+ version
69
+ end
70
+
71
+ def assign_identifiers(attributes)
72
+ @message_id = Utils.normalize_text!('message_id', attributes.fetch(:message_id), max: 35)
73
+ @payment_information_id = Utils.normalize_text!(
74
+ 'payment_information_id',
75
+ attributes.fetch(:payment_information_id),
76
+ max: 35
77
+ )
78
+ end
79
+
80
+ def assign_party_details(attributes)
81
+ @initiating_party_name = Utils.normalize_text!(
82
+ 'initiating_party_name',
83
+ attributes.fetch(:initiating_party_name),
84
+ max: 70
85
+ )
86
+ @debtor_name = Utils.normalize_text!('debtor_name', attributes.fetch(:debtor_name), max: 70)
87
+ @debtor_account = Account.new(iban: attributes.fetch(:debtor_iban), bic: attributes[:debtor_bic])
88
+ end
89
+
90
+ def assign_payment_schedule(attributes)
91
+ @requested_execution_date = Utils.normalize_date!(attributes.fetch(:requested_execution_date))
92
+ @transfers = normalize_transfers!(attributes.fetch(:transfers))
93
+ @batch_booking = attributes.fetch(:batch_booking, true) ? true : false
94
+ @created_at = Utils.normalize_time!(attributes.fetch(:created_at, Time.now.utc))
95
+ end
96
+
97
+ def normalize_transfers!(transfers)
98
+ value = Array(transfers)
99
+ raise ValidationError, 'at least one transfer is required' if value.empty?
100
+ raise ValidationError, 'transfers must all be Transfer instances' unless value.all?(Transfer)
101
+
102
+ value
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brot
4
+ # Supported pain.001 schema versions.
5
+ class PainVersion
6
+ attr_reader :identifier, :namespace, :serializer_class, :xsd_path
7
+
8
+ # @param identifier [String]
9
+ # @param namespace [String]
10
+ # @param serializer_class [Class]
11
+ # @param xsd_path [String]
12
+ def initialize(identifier:, namespace:, serializer_class:, xsd_path:)
13
+ @identifier = identifier.freeze
14
+ @namespace = namespace.freeze
15
+ @serializer_class = serializer_class
16
+ @xsd_path = xsd_path.freeze
17
+ freeze
18
+ end
19
+
20
+ PAIN_001_001_12 = new(
21
+ identifier: 'pain.001.001.12',
22
+ namespace: 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.12',
23
+ serializer_class: Serializers::Pain00100112,
24
+ xsd_path: File.expand_path('../../xsd/pain.001.001.12.xsd', __dir__)
25
+ )
26
+
27
+ PAIN_001_001_03 = new(
28
+ identifier: 'pain.001.001.03',
29
+ namespace: 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.03',
30
+ serializer_class: Serializers::Pain00100103,
31
+ xsd_path: File.expand_path('../../xsd/pain.001.001.03.xsd', __dir__)
32
+ )
33
+
34
+ ALL = [
35
+ PAIN_001_001_12,
36
+ PAIN_001_001_03
37
+ ].freeze
38
+
39
+ class << self
40
+ # @return [PainVersion]
41
+ def default
42
+ PAIN_001_001_03
43
+ end
44
+
45
+ # @return [Array<PainVersion>]
46
+ def all
47
+ ALL
48
+ end
49
+
50
+ # @param value [PainVersion, String, Symbol]
51
+ # @return [PainVersion]
52
+ def fetch(value)
53
+ return value if value.is_a?(PainVersion)
54
+
55
+ normalized = value.to_s.strip.downcase.tr('_', '.')
56
+ match = ALL.find { it.identifier == normalized }
57
+ return match if match
58
+
59
+ supported = ALL.map(&:identifier).join(', ')
60
+ raise ValidationError, "version must be one of #{supported}"
61
+ end
62
+
63
+ # @param namespace [String]
64
+ # @return [PainVersion]
65
+ def from_namespace(namespace)
66
+ match = ALL.find { it.namespace == namespace }
67
+ return match if match
68
+
69
+ raise ValidationError, "unsupported document namespace #{namespace.inspect}"
70
+ end
71
+ end
72
+
73
+ # @return [String]
74
+ def to_s
75
+ identifier
76
+ end
77
+
78
+ # @return [String]
79
+ def inspect
80
+ "#<#{self.class} #{identifier}>"
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brot
4
+ # XML schema validation helpers for generated payment files.
5
+ module Schema
6
+ # Result returned by schema validation.
7
+ Result = Data.define(:errors) do
8
+ # @return [Boolean]
9
+ def valid?
10
+ errors.empty?
11
+ end
12
+ end
13
+
14
+ module_function
15
+
16
+ # Absolute path to a bundled XSD file.
17
+ #
18
+ # @param version [PainVersion, Symbol, String]
19
+ # @return [String]
20
+ def bundled_xsd_path(version)
21
+ PainVersion.fetch(version).xsd_path
22
+ end
23
+
24
+ # Validates XML against the bundled schema for the requested version by default.
25
+ #
26
+ # @param xml [String]
27
+ # @param version [PainVersion, Symbol, String, nil]
28
+ # @param xsd_path [String, nil]
29
+ # @return [Result]
30
+ def validate(xml, version: nil, xsd_path: nil)
31
+ document = Nokogiri::XML(xml, &:strict)
32
+ schema_path = xsd_path || bundled_xsd_path(version || infer_version(document))
33
+ schema = Nokogiri::XML::Schema(File.read(schema_path))
34
+ errors = schema.validate(document).map { it.message.strip }
35
+
36
+ Result.new(errors: errors)
37
+ end
38
+
39
+ # Validates XML and raises when the schema check fails.
40
+ #
41
+ # @param xml [String]
42
+ # @param version [PainVersion, Symbol, String, nil]
43
+ # @param xsd_path [String, nil]
44
+ # @return [Result]
45
+ def validate!(xml, version: nil, xsd_path: nil)
46
+ result = validate(xml, version: version, xsd_path: xsd_path)
47
+ raise ValidationError, result.errors.join("\n") unless result.valid?
48
+
49
+ result
50
+ end
51
+
52
+ def infer_version(document)
53
+ namespace = document.root&.namespace&.href
54
+ return PainVersion.from_namespace(namespace) if namespace
55
+
56
+ raise ValidationError, 'xml document namespace is missing'
57
+ end
58
+ private_class_method :infer_version
59
+ end
60
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brot
4
+ # Helper methods shared by serializer implementations.
5
+ module SerializerWritingHelpers
6
+ GROUP_HEADER_FIELDS = [
7
+ [:MsgId, :message_id.to_proc],
8
+ [:CreDtTm, ->(doc) { doc.created_at.iso8601 }],
9
+ [:NbOfTxs, ->(doc) { doc.number_of_transactions.to_s }],
10
+ [:CtrlSum, ->(doc) { Utils.format_amount(doc.control_sum) }]
11
+ ].freeze
12
+
13
+ PAYMENT_INFORMATION_FIELDS = [
14
+ [:PmtInfId, :payment_information_id.to_proc],
15
+ [:PmtMtd, ->(_) { 'TRF' }],
16
+ [:BtchBookg, :batch_booking.to_proc],
17
+ [:NbOfTxs, ->(doc) { doc.number_of_transactions.to_s }],
18
+ [:CtrlSum, ->(doc) { Utils.format_amount(doc.control_sum) }]
19
+ ].freeze
20
+
21
+ private
22
+
23
+ def named_party(xml, element_name, name)
24
+ xml.public_send(element_name) { xml.Nm(name) }
25
+ end
26
+
27
+ def payment_header(xml)
28
+ write_fields(xml, PAYMENT_INFORMATION_FIELDS)
29
+ payment_type_information(xml)
30
+ requested_execution_date(xml)
31
+ end
32
+
33
+ def debtor_details(xml)
34
+ named_party(xml, :Dbtr, document.debtor_name)
35
+ debtor_account(xml)
36
+ agent(xml, :DbtrAgt, document.debtor_account)
37
+ xml.ChrgBr('SLEV')
38
+ end
39
+
40
+ def transfer_details(xml)
41
+ document.transfers.each { build_credit_transfer(xml, it) }
42
+ end
43
+
44
+ def write_fields(xml, fields)
45
+ fields.each do |element_name, extractor|
46
+ xml.public_send(element_name, extractor.call(document))
47
+ end
48
+ end
49
+ end
50
+
51
+ # Shared XML writer helpers for payment initiation serializers.
52
+ class SerializerBase
53
+ include SerializerWritingHelpers
54
+
55
+ # @param document [Document]
56
+ def initialize(document)
57
+ @document = document
58
+ end
59
+
60
+ # @param pretty [Boolean]
61
+ # @return [String]
62
+ def to_xml(pretty: true)
63
+ builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
64
+ build_document(xml)
65
+ end
66
+
67
+ return builder.to_xml if pretty
68
+
69
+ builder.doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :document
75
+
76
+ def build_credit_transfer(xml, transfer)
77
+ xml.CdtTrfTxInf do
78
+ payment_identification(xml, transfer)
79
+ amount(xml, transfer)
80
+ agent(xml, :CdtrAgt, transfer.creditor_account)
81
+ creditor(xml, transfer)
82
+ purpose(xml, transfer.purpose_code)
83
+ remittance_information(xml, transfer.remittance_information)
84
+ end
85
+ end
86
+
87
+ def build_document(xml)
88
+ xml.Document(xmlns: namespace) do
89
+ xml.CstmrCdtTrfInitn do
90
+ group_header(xml)
91
+ payment_information(xml)
92
+ end
93
+ end
94
+ end
95
+
96
+ def group_header(xml)
97
+ xml.GrpHdr do
98
+ write_fields(xml, GROUP_HEADER_FIELDS)
99
+ named_party(xml, :InitgPty, document.initiating_party_name)
100
+ end
101
+ end
102
+
103
+ def payment_information(xml)
104
+ xml.PmtInf do
105
+ payment_header(xml)
106
+ debtor_details(xml)
107
+ transfer_details(xml)
108
+ end
109
+ end
110
+
111
+ def payment_type_information(xml)
112
+ xml.PmtTpInf do
113
+ xml.SvcLvl { xml.Cd('SEPA') }
114
+ end
115
+ end
116
+
117
+ def payment_identification(xml, transfer)
118
+ xml.PmtId do
119
+ xml.InstrId(transfer.instruction_id) if transfer.instruction_id
120
+ xml.EndToEndId(transfer.end_to_end_id)
121
+ end
122
+ end
123
+
124
+ def amount(xml, transfer)
125
+ xml.Amt do
126
+ xml.InstdAmt(Utils.format_amount(transfer.amount), Ccy: transfer.currency)
127
+ end
128
+ end
129
+
130
+ def creditor(xml, transfer)
131
+ named_party(xml, :Cdtr, transfer.creditor_name)
132
+ xml.CdtrAcct { account_identification(xml, transfer.creditor_account) }
133
+ end
134
+
135
+ def debtor_account(xml)
136
+ xml.DbtrAcct { account_identification(xml, document.debtor_account) }
137
+ end
138
+
139
+ def account_identification(xml, account)
140
+ xml.Id { xml.IBAN(account.iban) }
141
+ end
142
+
143
+ def agent(xml, element_name, account)
144
+ xml.public_send(element_name) do
145
+ xml.FinInstnId do
146
+ if account.bic
147
+ xml.public_send(bic_element_name, account.bic)
148
+ else
149
+ other_financial_institution_id(xml)
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ def other_financial_institution_id(xml)
156
+ xml.Othr { xml.Id(Utils.default_financial_institution_id) }
157
+ end
158
+
159
+ def purpose(xml, purpose_code)
160
+ return unless purpose_code
161
+
162
+ xml.Purp { xml.Cd(purpose_code) }
163
+ end
164
+
165
+ def remittance_information(xml, value)
166
+ xml.RmtInf { xml.Ustrd(value) }
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brot
4
+ module Serializers
5
+ # Concrete serializer for pain.001.001.03 output.
6
+ class Pain00100103 < SerializerBase
7
+ private
8
+
9
+ def bic_element_name
10
+ :BIC
11
+ end
12
+
13
+ def namespace
14
+ 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.03'
15
+ end
16
+
17
+ def requested_execution_date(xml)
18
+ xml.ReqdExctnDt(document.requested_execution_date.iso8601)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,12 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brot
4
- module Pain00100112
4
+ module Serializers
5
5
  # Concrete serializer for pain.001.001.12 output.
6
- #
7
- # Most callers should use {Document#to_xml} instead of instantiating this
8
- # class directly.
9
- class Serializer < SerializerBase
6
+ class Pain00100112 < SerializerBase
10
7
  private
11
8
 
12
9
  def bic_element_name
@@ -14,7 +11,7 @@ module Brot
14
11
  end
15
12
 
16
13
  def namespace
17
- NAMESPACE
14
+ 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.12'
18
15
  end
19
16
 
20
17
  def requested_execution_date(xml)
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brot
4
+ # Single credit transfer entry inside a payment batch.
5
+ class Transfer
6
+ attr_reader :amount,
7
+ :creditor_account,
8
+ :creditor_name,
9
+ :currency,
10
+ :end_to_end_id,
11
+ :instruction_id,
12
+ :purpose_code,
13
+ :remittance_information
14
+
15
+ # @param attributes [Hash]
16
+ def initialize(**attributes)
17
+ assign_creditor_details(attributes)
18
+ assign_payment_references(attributes)
19
+ end
20
+
21
+ private
22
+
23
+ def assign_creditor_details(attributes)
24
+ @amount = Utils.normalize_amount!(attributes.fetch(:amount))
25
+ @currency = Utils.normalize_currency!(attributes.fetch(:currency, 'EUR'))
26
+ @creditor_name = Utils.normalize_text!('creditor_name', attributes.fetch(:creditor_name), max: 70)
27
+ @creditor_account = Account.new(iban: attributes.fetch(:creditor_iban), bic: attributes[:creditor_bic])
28
+ end
29
+
30
+ def assign_payment_references(attributes)
31
+ @end_to_end_id = Utils.normalize_text!('end_to_end_id', attributes.fetch(:end_to_end_id), max: 35)
32
+ @remittance_information = Utils.normalize_text!(
33
+ 'remittance_information',
34
+ attributes.fetch(:remittance_information),
35
+ max: 140
36
+ )
37
+ @instruction_id = normalize_optional_text(attributes[:instruction_id], 'instruction_id', 35)
38
+ @purpose_code = normalize_optional_text(attributes[:purpose_code], 'purpose_code', 4)
39
+ end
40
+
41
+ def normalize_optional_text(value, name, max)
42
+ value.nil? ? nil : Utils.normalize_text!(name, value, max: max)
43
+ end
44
+ end
45
+ end
data/lib/brot/utils.rb ADDED
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brot
4
+ # Normalization and validation helpers for payment data.
5
+ module Utils
6
+ module_function
7
+
8
+ def normalize_text!(name, value, max:)
9
+ text = String(value).strip
10
+ raise ValidationError, "#{name} must not be empty" if text.empty?
11
+ raise ValidationError, "#{name} exceeds #{max} characters" if text.length > max
12
+
13
+ text
14
+ end
15
+
16
+ def normalize_iban!(iban)
17
+ value = String(iban).upcase.delete(' ')
18
+ raise ValidationError, 'iban is invalid' unless valid_iban?(value)
19
+
20
+ value
21
+ end
22
+
23
+ def normalize_bic!(bic)
24
+ value = String(bic).upcase.strip
25
+ raise ValidationError, 'bic is invalid' unless /\A[A-Z0-9]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\z/.match?(value)
26
+
27
+ value
28
+ end
29
+
30
+ def normalize_amount!(amount)
31
+ value = BigDecimal(amount.to_s)
32
+ raise ValidationError, 'amount must be positive' unless value.positive?
33
+ raise ValidationError, 'amount must have at most two decimal places' unless two_decimal_places?(value)
34
+
35
+ value
36
+ rescue ArgumentError
37
+ raise ValidationError, 'amount is invalid'
38
+ end
39
+
40
+ def normalize_currency!(currency)
41
+ value = String(currency).upcase.strip
42
+ raise ValidationError, 'currency is invalid' unless /\A[A-Z]{3}\z/.match?(value)
43
+
44
+ value
45
+ end
46
+
47
+ def normalize_date!(date)
48
+ return date if date.is_a?(Date)
49
+ return Date.iso8601(date) if date.is_a?(String)
50
+
51
+ raise ValidationError, 'requested_execution_date must be a Date or ISO 8601 string'
52
+ end
53
+
54
+ def normalize_time!(value)
55
+ return value.getutc if value.respond_to?(:getutc)
56
+ return Time.iso8601(value).getutc if value.is_a?(String)
57
+
58
+ raise ValidationError, 'created_at must be a Time-like object or ISO 8601 string'
59
+ end
60
+
61
+ def format_amount(amount)
62
+ Kernel.format('%.2f', amount)
63
+ end
64
+
65
+ def default_financial_institution_id
66
+ 'NOTPROVIDED'
67
+ end
68
+
69
+ def valid_iban?(value)
70
+ return false unless /\A[A-Z]{2}\d{2}[A-Z0-9]{11,30}\z/.match?(value)
71
+
72
+ iban_mod97(value) == 1
73
+ end
74
+
75
+ def iban_mod97(iban)
76
+ rearranged = "#{iban[4..]}#{iban[0, 4]}"
77
+ remainder = 0
78
+
79
+ rearranged.each_char do |char|
80
+ chunk = "#{remainder}#{char.ord >= 65 ? char.ord - 55 : char}"
81
+ remainder = chunk.to_i % 97
82
+ end
83
+
84
+ remainder
85
+ end
86
+
87
+ def two_decimal_places?(value)
88
+ ((value * 100) % 1).zero?
89
+ end
90
+ end
91
+ end
data/lib/brot/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brot
4
- VERSION = '0.1.1'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/brot.rb CHANGED
@@ -7,7 +7,16 @@ require 'time'
7
7
 
8
8
  module Brot
9
9
  class Error < StandardError; end
10
+ class ValidationError < Error; end
10
11
  end
11
12
 
12
13
  require_relative 'brot/version'
13
- require_relative 'brot/pain00100112'
14
+ require_relative 'brot/utils'
15
+ require_relative 'brot/account'
16
+ require_relative 'brot/transfer'
17
+ require_relative 'brot/serializer_base'
18
+ require_relative 'brot/serializers/pain00100112'
19
+ require_relative 'brot/serializers/pain00100103'
20
+ require_relative 'brot/pain_version'
21
+ require_relative 'brot/schema'
22
+ require_relative 'brot/document'