brot 0.1.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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +25 -0
- data/.github/workflows/release.yml +43 -0
- data/.rubocop.yml +9 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +22 -0
- data/README.md +424 -0
- data/Rakefile +10 -0
- data/brot.gemspec +44 -0
- data/lib/brot/pain00100112/account.rb +48 -0
- data/lib/brot/pain00100112/document.rb +162 -0
- data/lib/brot/pain00100112/schema.rb +58 -0
- data/lib/brot/pain00100112/serializer.rb +25 -0
- data/lib/brot/pain00100112/serializer_base.rb +174 -0
- data/lib/brot/pain00100112/transfer.rb +96 -0
- data/lib/brot/pain00100112/utils.rb +92 -0
- data/lib/brot/pain00100112.rb +25 -0
- data/lib/brot/version.rb +5 -0
- data/lib/brot.rb +13 -0
- data/test/brot_test.rb +9 -0
- data/test/pain00100112_document_test.rb +87 -0
- data/test/test_helper.rb +5 -0
- data/xsd/pain.001.001.12.xsd +1208 -0
- metadata +82 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brot
|
|
4
|
+
module Pain00100112
|
|
5
|
+
# Payment batch document that serializes into pain.001.001.12 XML.
|
|
6
|
+
#
|
|
7
|
+
# This is the main entry point of the gem. A document corresponds to one
|
|
8
|
+
# complete XML file containing one debtor and one or more outgoing credit
|
|
9
|
+
# transfers.
|
|
10
|
+
#
|
|
11
|
+
# @example Build and validate a payment file
|
|
12
|
+
# transfer = Brot::Pain00100112::Transfer.new(
|
|
13
|
+
# amount: '1250.50',
|
|
14
|
+
# creditor_name: 'Example Supplier GmbH',
|
|
15
|
+
# creditor_iban: 'DE89370400440532013000',
|
|
16
|
+
# end_to_end_id: 'INV-2026-0001',
|
|
17
|
+
# remittance_information: 'Invoice 2026-0001'
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# document = Brot::Pain00100112::Document.new(
|
|
21
|
+
# message_id: 'MSG-20260313-01',
|
|
22
|
+
# payment_information_id: 'PMT-20260313-01',
|
|
23
|
+
# initiating_party_name: 'Example Debtor GmbH',
|
|
24
|
+
# debtor_name: 'Example Debtor GmbH',
|
|
25
|
+
# debtor_iban: 'DE12500105170648489890',
|
|
26
|
+
# requested_execution_date: Date.new(2026, 3, 13),
|
|
27
|
+
# transfers: [transfer]
|
|
28
|
+
# )
|
|
29
|
+
#
|
|
30
|
+
# result = document.validate
|
|
31
|
+
# raise result.errors.join("\n") unless result.valid?
|
|
32
|
+
#
|
|
33
|
+
# @attr_reader message_id
|
|
34
|
+
# Group header message identifier.
|
|
35
|
+
# Example: `MSG-20260313-01`
|
|
36
|
+
# @attr_reader payment_information_id
|
|
37
|
+
# Payment information block identifier.
|
|
38
|
+
# Example: `PMT-20260313-01`
|
|
39
|
+
# @attr_reader initiating_party_name
|
|
40
|
+
# Name written into `GrpHdr/InitgPty/Nm`.
|
|
41
|
+
# Example: `Example Debtor GmbH`
|
|
42
|
+
# @attr_reader debtor_name
|
|
43
|
+
# Name written into `PmtInf/Dbtr/Nm`.
|
|
44
|
+
# Example: `Example Debtor GmbH`
|
|
45
|
+
# @attr_reader debtor_account
|
|
46
|
+
# Debtor bank account as an {Account}.
|
|
47
|
+
# @attr_reader requested_execution_date
|
|
48
|
+
# Requested execution date as a {Date}.
|
|
49
|
+
# @attr_reader transfers
|
|
50
|
+
# Array of {Transfer} objects.
|
|
51
|
+
# @attr_reader batch_booking
|
|
52
|
+
# Whether the file asks for batch booking.
|
|
53
|
+
# @attr_reader created_at
|
|
54
|
+
# Creation time written into `GrpHdr/CreDtTm`.
|
|
55
|
+
class Document
|
|
56
|
+
attr_reader :batch_booking,
|
|
57
|
+
:created_at,
|
|
58
|
+
:debtor_account,
|
|
59
|
+
:debtor_name,
|
|
60
|
+
:initiating_party_name,
|
|
61
|
+
:message_id,
|
|
62
|
+
:payment_information_id,
|
|
63
|
+
:requested_execution_date,
|
|
64
|
+
:transfers
|
|
65
|
+
|
|
66
|
+
# @param attributes [Hash]
|
|
67
|
+
# @option attributes [String] :message_id
|
|
68
|
+
# Example: `MSG-20260313-01`
|
|
69
|
+
# @option attributes [String] :payment_information_id
|
|
70
|
+
# Example: `PMT-20260313-01`
|
|
71
|
+
# @option attributes [String] :initiating_party_name
|
|
72
|
+
# Example: `Example Debtor GmbH`
|
|
73
|
+
# @option attributes [String] :debtor_name
|
|
74
|
+
# Example: `Example Debtor GmbH`
|
|
75
|
+
# @option attributes [String] :debtor_iban
|
|
76
|
+
# Example: `DE12500105170648489890`
|
|
77
|
+
# @option attributes [String, nil] :debtor_bic
|
|
78
|
+
# Example: `INGDDEFFXXX`
|
|
79
|
+
# @option attributes [Date, String] :requested_execution_date
|
|
80
|
+
# Example: `Date.new(2026, 3, 13)` or `'2026-03-13'`
|
|
81
|
+
# @option attributes [Array<Transfer>] :transfers
|
|
82
|
+
# Must contain at least one transfer.
|
|
83
|
+
# @option attributes [Boolean] :batch_booking
|
|
84
|
+
# Defaults to `true`.
|
|
85
|
+
# @option attributes [Time, String] :created_at
|
|
86
|
+
# Defaults to the current UTC time.
|
|
87
|
+
def initialize(**attributes)
|
|
88
|
+
assign_identifiers(attributes)
|
|
89
|
+
assign_party_details(attributes)
|
|
90
|
+
assign_payment_schedule(attributes)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns the sum of all transfer amounts.
|
|
94
|
+
#
|
|
95
|
+
# @return [BigDecimal]
|
|
96
|
+
def control_sum
|
|
97
|
+
transfers.sum(BigDecimal('0'), &:amount)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns the number of transfers in the document.
|
|
101
|
+
#
|
|
102
|
+
# @return [Integer]
|
|
103
|
+
def number_of_transactions
|
|
104
|
+
transfers.length
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Serializes the document into `pain.001.001.12` XML.
|
|
108
|
+
#
|
|
109
|
+
# @param pretty [Boolean]
|
|
110
|
+
# When `true`, outputs formatted XML with indentation.
|
|
111
|
+
# @return [String]
|
|
112
|
+
def to_xml(pretty: true)
|
|
113
|
+
Serializer.new(self).to_xml(pretty: pretty)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Validates the generated XML against the bundled schema by default.
|
|
117
|
+
#
|
|
118
|
+
# @param xsd_path [String]
|
|
119
|
+
# Optional custom XSD path.
|
|
120
|
+
# @return [Schema::Result]
|
|
121
|
+
def validate(xsd_path: Schema.bundled_xsd_path)
|
|
122
|
+
Schema.validate(to_xml(pretty: false), xsd_path: xsd_path)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def assign_identifiers(attributes)
|
|
128
|
+
@message_id = Utils.normalize_text!('message_id', attributes.fetch(:message_id), max: 35)
|
|
129
|
+
@payment_information_id = Utils.normalize_text!(
|
|
130
|
+
'payment_information_id',
|
|
131
|
+
attributes.fetch(:payment_information_id),
|
|
132
|
+
max: 35
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def assign_party_details(attributes)
|
|
137
|
+
@initiating_party_name = Utils.normalize_text!(
|
|
138
|
+
'initiating_party_name',
|
|
139
|
+
attributes.fetch(:initiating_party_name),
|
|
140
|
+
max: 70
|
|
141
|
+
)
|
|
142
|
+
@debtor_name = Utils.normalize_text!('debtor_name', attributes.fetch(:debtor_name), max: 70)
|
|
143
|
+
@debtor_account = Account.new(iban: attributes.fetch(:debtor_iban), bic: attributes[:debtor_bic])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def assign_payment_schedule(attributes)
|
|
147
|
+
@requested_execution_date = Utils.normalize_date!(attributes.fetch(:requested_execution_date))
|
|
148
|
+
@transfers = normalize_transfers!(attributes.fetch(:transfers))
|
|
149
|
+
@batch_booking = attributes.fetch(:batch_booking, true) ? true : false
|
|
150
|
+
@created_at = Utils.normalize_time!(attributes.fetch(:created_at, Time.now.utc))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def normalize_transfers!(transfers)
|
|
154
|
+
value = Array(transfers)
|
|
155
|
+
raise ValidationError, 'at least one transfer is required' if value.empty?
|
|
156
|
+
raise ValidationError, 'transfers must all be Transfer instances' unless value.all?(Transfer)
|
|
157
|
+
|
|
158
|
+
value
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brot
|
|
4
|
+
module Pain00100112
|
|
5
|
+
# XML schema validation helpers for generated payment files.
|
|
6
|
+
#
|
|
7
|
+
# Use this module when you already have an XML string and want to validate
|
|
8
|
+
# it against the bundled `pain.001.001.12.xsd`.
|
|
9
|
+
#
|
|
10
|
+
# @example Validate XML directly
|
|
11
|
+
# xml = document.to_xml(pretty: false)
|
|
12
|
+
# result = Brot::Pain00100112::Schema.validate(xml)
|
|
13
|
+
#
|
|
14
|
+
# raise result.errors.join("\n") unless result.valid?
|
|
15
|
+
module Schema
|
|
16
|
+
# Result returned by schema validation.
|
|
17
|
+
#
|
|
18
|
+
# @example Check a validation result
|
|
19
|
+
# result = Brot::Pain00100112::Schema.validate(xml)
|
|
20
|
+
#
|
|
21
|
+
# result.valid?
|
|
22
|
+
# # => true
|
|
23
|
+
#
|
|
24
|
+
# result.errors
|
|
25
|
+
# # => []
|
|
26
|
+
Result = Data.define(:errors) do
|
|
27
|
+
# @return [Boolean]
|
|
28
|
+
def valid?
|
|
29
|
+
errors.empty?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Absolute path to the XSD file bundled inside the gem.
|
|
36
|
+
#
|
|
37
|
+
# @return [String]
|
|
38
|
+
def bundled_xsd_path
|
|
39
|
+
File.expand_path('../../../xsd/pain.001.001.12.xsd', __dir__)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Validates XML against the bundled schema by default.
|
|
43
|
+
#
|
|
44
|
+
# @param xml [String]
|
|
45
|
+
# A `pain.001.001.12` XML document.
|
|
46
|
+
# @param xsd_path [String]
|
|
47
|
+
# Optional custom XSD path.
|
|
48
|
+
# @return [Result]
|
|
49
|
+
def validate(xml, xsd_path: bundled_xsd_path)
|
|
50
|
+
schema = Nokogiri::XML::Schema(File.read(xsd_path))
|
|
51
|
+
document = Nokogiri::XML(xml, &:strict)
|
|
52
|
+
errors = schema.validate(document).map { it.message.strip }
|
|
53
|
+
|
|
54
|
+
Result.new(errors: errors)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brot
|
|
4
|
+
module Pain00100112
|
|
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
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def bic_element_name
|
|
13
|
+
:BICFI
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def namespace
|
|
17
|
+
NAMESPACE
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def requested_execution_date(xml)
|
|
21
|
+
xml.ReqdExctnDt { xml.Dt(document.requested_execution_date.iso8601) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brot
|
|
4
|
+
module Pain00100112
|
|
5
|
+
# Helper methods shared by serializer implementations.
|
|
6
|
+
module SerializerWritingHelpers
|
|
7
|
+
GROUP_HEADER_FIELDS = [
|
|
8
|
+
[:MsgId, :message_id.to_proc],
|
|
9
|
+
[:CreDtTm, ->(doc) { doc.created_at.iso8601 }],
|
|
10
|
+
[:NbOfTxs, ->(doc) { doc.number_of_transactions.to_s }],
|
|
11
|
+
[:CtrlSum, ->(doc) { Utils.format_amount(doc.control_sum) }]
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
PAYMENT_INFORMATION_FIELDS = [
|
|
15
|
+
[:PmtInfId, :payment_information_id.to_proc],
|
|
16
|
+
[:PmtMtd, ->(_) { 'TRF' }],
|
|
17
|
+
[:BtchBookg, :batch_booking.to_proc],
|
|
18
|
+
[:NbOfTxs, ->(doc) { doc.number_of_transactions.to_s }],
|
|
19
|
+
[:CtrlSum, ->(doc) { Utils.format_amount(doc.control_sum) }]
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def named_party(xml, element_name, name)
|
|
25
|
+
xml.public_send(element_name) { xml.Nm(name) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def payment_header(xml)
|
|
29
|
+
write_fields(xml, PAYMENT_INFORMATION_FIELDS)
|
|
30
|
+
payment_type_information(xml)
|
|
31
|
+
requested_execution_date(xml)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def debtor_details(xml)
|
|
35
|
+
named_party(xml, :Dbtr, document.debtor_name)
|
|
36
|
+
debtor_account(xml)
|
|
37
|
+
agent(xml, :DbtrAgt, document.debtor_account)
|
|
38
|
+
xml.ChrgBr('SLEV')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def transfer_details(xml)
|
|
42
|
+
document.transfers.each { build_credit_transfer(xml, it) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def write_fields(xml, fields)
|
|
46
|
+
fields.each do |element_name, extractor|
|
|
47
|
+
xml.public_send(element_name, extractor.call(document))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Shared XML writer helpers for payment initiation serializers.
|
|
53
|
+
#
|
|
54
|
+
# This is an internal base class. For normal application code, use
|
|
55
|
+
# {Document#to_xml} rather than calling serializer classes directly.
|
|
56
|
+
class SerializerBase
|
|
57
|
+
include SerializerWritingHelpers
|
|
58
|
+
|
|
59
|
+
# @param document [Document]
|
|
60
|
+
def initialize(document)
|
|
61
|
+
@document = document
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param pretty [Boolean]
|
|
65
|
+
# @return [String]
|
|
66
|
+
def to_xml(pretty: true)
|
|
67
|
+
builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
|
|
68
|
+
build_document(xml)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return builder.to_xml if pretty
|
|
72
|
+
|
|
73
|
+
builder.doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
attr_reader :document
|
|
79
|
+
|
|
80
|
+
def build_credit_transfer(xml, transfer)
|
|
81
|
+
xml.CdtTrfTxInf do
|
|
82
|
+
payment_identification(xml, transfer)
|
|
83
|
+
amount(xml, transfer.amount)
|
|
84
|
+
agent(xml, :CdtrAgt, transfer.creditor_account)
|
|
85
|
+
creditor(xml, transfer)
|
|
86
|
+
purpose(xml, transfer.purpose_code)
|
|
87
|
+
remittance_information(xml, transfer.remittance_information)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_document(xml)
|
|
92
|
+
xml.Document(xmlns: namespace) do
|
|
93
|
+
xml.CstmrCdtTrfInitn do
|
|
94
|
+
group_header(xml)
|
|
95
|
+
payment_information(xml)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def group_header(xml)
|
|
101
|
+
xml.GrpHdr do
|
|
102
|
+
write_fields(xml, GROUP_HEADER_FIELDS)
|
|
103
|
+
named_party(xml, :InitgPty, document.initiating_party_name)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def payment_information(xml)
|
|
108
|
+
xml.PmtInf do
|
|
109
|
+
payment_header(xml)
|
|
110
|
+
debtor_details(xml)
|
|
111
|
+
transfer_details(xml)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def payment_type_information(xml)
|
|
116
|
+
xml.PmtTpInf do
|
|
117
|
+
xml.SvcLvl { xml.Cd('SEPA') }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def payment_identification(xml, transfer)
|
|
122
|
+
xml.PmtId do
|
|
123
|
+
xml.InstrId(transfer.instruction_id) if transfer.instruction_id
|
|
124
|
+
xml.EndToEndId(transfer.end_to_end_id)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def amount(xml, amount)
|
|
129
|
+
xml.Amt do
|
|
130
|
+
xml.InstdAmt(Utils.format_amount(amount), Ccy: 'EUR')
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def creditor(xml, transfer)
|
|
135
|
+
named_party(xml, :Cdtr, transfer.creditor_name)
|
|
136
|
+
xml.CdtrAcct { account_identification(xml, transfer.creditor_account) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def debtor_account(xml)
|
|
140
|
+
xml.DbtrAcct { account_identification(xml, document.debtor_account) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def account_identification(xml, account)
|
|
144
|
+
xml.Id { xml.IBAN(account.iban) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def agent(xml, element_name, account)
|
|
148
|
+
xml.public_send(element_name) do
|
|
149
|
+
xml.FinInstnId do
|
|
150
|
+
if account.bic
|
|
151
|
+
xml.public_send(bic_element_name, account.bic)
|
|
152
|
+
else
|
|
153
|
+
other_financial_institution_id(xml)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def other_financial_institution_id(xml)
|
|
160
|
+
xml.Othr { xml.Id(Utils.default_financial_institution_id) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def purpose(xml, purpose_code)
|
|
164
|
+
return unless purpose_code
|
|
165
|
+
|
|
166
|
+
xml.Purp { xml.Cd(purpose_code) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def remittance_information(xml, value)
|
|
170
|
+
xml.RmtInf { xml.Ustrd(value) }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brot
|
|
4
|
+
module Pain00100112
|
|
5
|
+
# Single credit transfer entry inside a payment batch.
|
|
6
|
+
#
|
|
7
|
+
# Each instance becomes one `CdtTrfTxInf` block inside the output XML.
|
|
8
|
+
#
|
|
9
|
+
# @example Create a transfer
|
|
10
|
+
# transfer = Brot::Pain00100112::Transfer.new(
|
|
11
|
+
# amount: '1250.50',
|
|
12
|
+
# creditor_name: 'Example Supplier GmbH',
|
|
13
|
+
# creditor_iban: 'DE89370400440532013000',
|
|
14
|
+
# creditor_bic: 'COBADEFFXXX',
|
|
15
|
+
# end_to_end_id: 'INV-2026-0001',
|
|
16
|
+
# remittance_information: 'Invoice 2026-0001',
|
|
17
|
+
# instruction_id: 'PAY-0001',
|
|
18
|
+
# purpose_code: 'SUPP'
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# @attr_reader amount
|
|
22
|
+
# Transfer amount as a `BigDecimal`.
|
|
23
|
+
# Example input: `'1250.50'`
|
|
24
|
+
# @attr_reader creditor_name
|
|
25
|
+
# Name written into `Cdtr/Nm`.
|
|
26
|
+
# Example: `Example Supplier GmbH`
|
|
27
|
+
# @attr_reader creditor_account
|
|
28
|
+
# Creditor bank account as an {Account}.
|
|
29
|
+
# @attr_reader end_to_end_id
|
|
30
|
+
# End-to-end reference.
|
|
31
|
+
# Example: `INV-2026-0001`
|
|
32
|
+
# @attr_reader remittance_information
|
|
33
|
+
# Unstructured remittance information.
|
|
34
|
+
# Example: `Invoice 2026-0001`
|
|
35
|
+
# @attr_reader instruction_id
|
|
36
|
+
# Optional instruction identifier.
|
|
37
|
+
# Example: `PAY-0001`
|
|
38
|
+
# @attr_reader purpose_code
|
|
39
|
+
# Optional purpose code.
|
|
40
|
+
# Example: `SUPP`
|
|
41
|
+
class Transfer
|
|
42
|
+
attr_reader :amount,
|
|
43
|
+
:creditor_account,
|
|
44
|
+
:creditor_name,
|
|
45
|
+
:end_to_end_id,
|
|
46
|
+
:instruction_id,
|
|
47
|
+
:purpose_code,
|
|
48
|
+
:remittance_information
|
|
49
|
+
|
|
50
|
+
# @param attributes [Hash]
|
|
51
|
+
# @option attributes [String, Numeric] :amount
|
|
52
|
+
# Example: `'1250.50'`
|
|
53
|
+
# @option attributes [String] :creditor_name
|
|
54
|
+
# Example: `Example Supplier GmbH`
|
|
55
|
+
# @option attributes [String] :creditor_iban
|
|
56
|
+
# Example: `DE89370400440532013000`
|
|
57
|
+
# @option attributes [String, nil] :creditor_bic
|
|
58
|
+
# Example: `COBADEFFXXX`
|
|
59
|
+
# @option attributes [String] :end_to_end_id
|
|
60
|
+
# Example: `INV-2026-0001`
|
|
61
|
+
# @option attributes [String] :remittance_information
|
|
62
|
+
# Example: `Invoice 2026-0001`
|
|
63
|
+
# @option attributes [String, nil] :instruction_id
|
|
64
|
+
# Example: `PAY-0001`
|
|
65
|
+
# @option attributes [String, nil] :purpose_code
|
|
66
|
+
# Example: `SUPP`
|
|
67
|
+
def initialize(**attributes)
|
|
68
|
+
assign_creditor_details(attributes)
|
|
69
|
+
assign_payment_references(attributes)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def assign_creditor_details(attributes)
|
|
75
|
+
@amount = Utils.normalize_amount!(attributes.fetch(:amount))
|
|
76
|
+
@creditor_name = Utils.normalize_text!('creditor_name', attributes.fetch(:creditor_name), max: 70)
|
|
77
|
+
@creditor_account = Account.new(iban: attributes.fetch(:creditor_iban), bic: attributes[:creditor_bic])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def assign_payment_references(attributes)
|
|
81
|
+
@end_to_end_id = Utils.normalize_text!('end_to_end_id', attributes.fetch(:end_to_end_id), max: 35)
|
|
82
|
+
@remittance_information = Utils.normalize_text!(
|
|
83
|
+
'remittance_information',
|
|
84
|
+
attributes.fetch(:remittance_information),
|
|
85
|
+
max: 140
|
|
86
|
+
)
|
|
87
|
+
@instruction_id = normalize_optional_text(attributes[:instruction_id], 'instruction_id', 35)
|
|
88
|
+
@purpose_code = normalize_optional_text(attributes[:purpose_code], 'purpose_code', 4)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def normalize_optional_text(value, name, max)
|
|
92
|
+
value.nil? ? nil : Utils.normalize_text!(name, value, max: max)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brot
|
|
4
|
+
module Pain00100112
|
|
5
|
+
# Normalization and validation helpers for payment data.
|
|
6
|
+
#
|
|
7
|
+
# This module is internal. Application code should normally use the public
|
|
8
|
+
# classes {Account}, {Transfer}, {Document}, and {Schema}.
|
|
9
|
+
module Utils
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def normalize_text!(name, value, max:)
|
|
13
|
+
text = String(value).strip
|
|
14
|
+
raise ValidationError, "#{name} must not be empty" if text.empty?
|
|
15
|
+
raise ValidationError, "#{name} exceeds #{max} characters" if text.length > max
|
|
16
|
+
|
|
17
|
+
text
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def normalize_iban!(iban)
|
|
21
|
+
value = String(iban).upcase.delete(' ')
|
|
22
|
+
raise ValidationError, 'iban is invalid' unless valid_iban?(value)
|
|
23
|
+
|
|
24
|
+
value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def normalize_bic!(bic)
|
|
28
|
+
value = String(bic).upcase.strip
|
|
29
|
+
unless /\A[A-Z0-9]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\z/.match?(value)
|
|
30
|
+
raise ValidationError,
|
|
31
|
+
'bic is invalid'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def normalize_amount!(amount)
|
|
38
|
+
value = BigDecimal(amount.to_s)
|
|
39
|
+
raise ValidationError, 'amount must be positive' unless value.positive?
|
|
40
|
+
raise ValidationError, 'amount must have at most two decimal places' unless two_decimal_places?(value)
|
|
41
|
+
|
|
42
|
+
value
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
raise ValidationError, 'amount is invalid'
|
|
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
|
|
92
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Brot
|
|
4
|
+
module Pain00100112
|
|
5
|
+
# Public namespace for the pain.001.001.12 implementation.
|
|
6
|
+
#
|
|
7
|
+
# Main entry points:
|
|
8
|
+
#
|
|
9
|
+
# - {Document} for building complete payment files
|
|
10
|
+
# - {Transfer} for individual outgoing payments
|
|
11
|
+
# - {Schema} for validating XML against the bundled XSD
|
|
12
|
+
NAMESPACE = 'urn:iso:std:iso:20022:tech:xsd:pain.001.001.12'
|
|
13
|
+
|
|
14
|
+
# Raised when input data cannot be normalized into a valid payment file.
|
|
15
|
+
class ValidationError < Brot::Error; end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require_relative 'pain00100112/account'
|
|
20
|
+
require_relative 'pain00100112/document'
|
|
21
|
+
require_relative 'pain00100112/schema'
|
|
22
|
+
require_relative 'pain00100112/serializer_base'
|
|
23
|
+
require_relative 'pain00100112/serializer'
|
|
24
|
+
require_relative 'pain00100112/transfer'
|
|
25
|
+
require_relative 'pain00100112/utils'
|
data/lib/brot/version.rb
ADDED
data/lib/brot.rb
ADDED