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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/release.yml +2 -2
- data/.rubocop.yml +5 -0
- data/README.md +169 -269
- data/brot.gemspec +2 -2
- data/lib/brot/account.rb +22 -0
- data/lib/brot/document.rb +105 -0
- data/lib/brot/pain_version.rb +83 -0
- data/lib/brot/schema.rb +60 -0
- data/lib/brot/serializer_base.rb +169 -0
- data/lib/brot/serializers/pain00100103.rb +22 -0
- data/lib/brot/{pain00100112/serializer.rb → serializers/pain00100112.rb} +3 -6
- data/lib/brot/transfer.rb +45 -0
- data/lib/brot/utils.rb +91 -0
- data/lib/brot/version.rb +1 -1
- data/lib/brot.rb +10 -1
- data/test/currency_support_test.rb +102 -0
- data/test/document_test.rb +155 -0
- data/test/transfer_test.rb +33 -0
- data/xsd/pain.001.001.03.xsd +921 -0
- metadata +16 -12
- data/lib/brot/pain00100112/account.rb +0 -48
- data/lib/brot/pain00100112/document.rb +0 -162
- data/lib/brot/pain00100112/schema.rb +0 -58
- data/lib/brot/pain00100112/serializer_base.rb +0 -174
- data/lib/brot/pain00100112/transfer.rb +0 -96
- data/lib/brot/pain00100112/utils.rb +0 -92
- data/lib/brot/pain00100112.rb +0 -25
- data/test/pain00100112_document_test.rb +0 -87
|
@@ -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
|
data/lib/brot/schema.rb
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
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/
|
|
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'
|