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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bf6f5e4155bf59cb2cc724e9ea723bb066d5c95e745e687b66e36ad4d058d81f
4
+ data.tar.gz: 931cfa4d40375498939bdec52b97b9f63987e16e9efa825aec351987f7b97d6e
5
+ SHA512:
6
+ metadata.gz: 46155574730e67a42bba7c1a815f1be3dcec94c2fda775a66b646b6237b6f51ee83f1d15907a4d4ed503ef9d0195316a42891d64acaa7326b83b1cca542651e5
7
+ data.tar.gz: 9e5c22fdb7710f461e28639032e8534c8aafed98c77943e8ba4c27460cda071db18a0bdb10b0f1f11036cd44e117d1acea521762fac85e3c7971a06fd78beaf7
@@ -0,0 +1,25 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: ruby/setup-ruby@v1
17
+ with:
18
+ ruby-version: "4.0"
19
+ bundler-cache: true
20
+
21
+ - name: Run tests
22
+ run: bundle exec rake test
23
+
24
+ - name: Run RuboCop
25
+ run: bundle exec rubocop
@@ -0,0 +1,43 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: ruby/setup-ruby@v1
16
+ with:
17
+ ruby-version: '4.0'
18
+ bundler-cache: true
19
+
20
+ - name: Run tests
21
+ run: bundle exec rake test
22
+
23
+ - name: Run RuboCop
24
+ run: bundle exec rubocop
25
+
26
+ release:
27
+ name: Publish to RubyGems
28
+ needs: [test]
29
+ runs-on: ubuntu-latest
30
+ environment: release
31
+ permissions:
32
+ id-token: write
33
+ contents: write
34
+
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+
38
+ - uses: ruby/setup-ruby@v1
39
+ with:
40
+ ruby-version: '4.0'
41
+ bundler-cache: true
42
+
43
+ - uses: rubygems/release-gem@v1
data/.rubocop.yml ADDED
@@ -0,0 +1,9 @@
1
+ plugins:
2
+ - rubocop-minitest
3
+ - rubocop-rake
4
+
5
+ AllCops:
6
+ NewCops: enable
7
+ SuggestExtensions: false
8
+ TargetRubyVersion: 4.0
9
+
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ group :development do
8
+ gem 'minitest', '~> 5.25'
9
+ gem 'rake', '~> 13.3'
10
+ gem 'rubocop', '~> 1.76'
11
+ gem 'rubocop-minitest', '~> 0.38'
12
+ gem 'rubocop-rake', '~> 0.7'
13
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vincent Garrigues
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
data/README.md ADDED
@@ -0,0 +1,424 @@
1
+ # brot
2
+
3
+ `brot` builds ISO 20022 SEPA credit transfer XML for DATEV-style uploads with
4
+ `Nokogiri::XML::Builder`.
5
+
6
+ The gem emits `pain.001.001.12` and is designed to validate directly against
7
+ the provided `pain.001.001.12.xsd`.
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ gem 'brot'
13
+ ```
14
+
15
+ ## What The Gem Does
16
+
17
+ `brot` is intentionally focused on one narrow job:
18
+
19
+ - Generate `pain.001.001.12` customer credit transfer files.
20
+ - Build XML with `Nokogiri::XML::Builder`.
21
+ - Validate generated XML against the bundled `pain.001.001.12.xsd`.
22
+ - Stay close to the DATEV upload use case instead of exposing the full ISO
23
+ 20022 surface area.
24
+
25
+ Current assumptions baked into the serializer:
26
+
27
+ - Service level is always `SEPA`.
28
+ - Payment method is always `TRF`.
29
+ - Charge bearer is always `SLEV`.
30
+ - Instructed amount is always emitted as `EUR`.
31
+ - A debtor BIC or creditor BIC is optional. If absent, the XML uses the
32
+ fallback financial institution identifier `NOTPROVIDED`.
33
+
34
+ ## Scope And Gaps
35
+
36
+ `brot` is intentionally a DATEV-oriented subset of `pain.001.001.12`, not a
37
+ full abstraction over every branch allowed by the ISO 20022 schema.
38
+
39
+ What is currently supported:
40
+
41
+ - SEPA credit transfers
42
+ - One debtor per document
43
+ - One payment information block (`PmtInf`) per document
44
+ - One or more credit transfer entries (`CdtTrfTxInf`)
45
+ - Debtor and creditor names
46
+ - Debtor and creditor IBANs
47
+ - Optional debtor and creditor BICs
48
+ - End-to-end IDs
49
+ - Optional instruction IDs
50
+ - Optional purpose codes
51
+ - Unstructured remittance information (`RmtInf/Ustrd`)
52
+ - Validation against the bundled `pain.001.001.12.xsd`
53
+
54
+ What is not modeled yet, even though the schema allows it:
55
+
56
+ - Structured remittance information
57
+ - Postal addresses and richer party identification data
58
+ - Ultimate debtor and ultimate creditor
59
+ - Multiple `PmtInf` sections in one document
60
+ - Non-SEPA service levels or non-EUR transfer setups
61
+ - Local instrument and category purpose variants beyond the current subset
62
+ - Intermediary agents and extra account layers
63
+ - Regulatory reporting, tax, cheque, and supplementary data sections
64
+
65
+ The practical meaning is:
66
+
67
+ - The gem aims to produce a clean DATEV-style payment initiation file for the
68
+ supported SEPA use case.
69
+ - The gem does not yet try to represent the entire `pain.001.001.12` standard
70
+ as a generic Ruby object model.
71
+
72
+ ## Quick Start
73
+
74
+ ```ruby
75
+ require 'brot'
76
+ require 'date'
77
+
78
+ transfer = Brot::Pain00100112::Transfer.new(
79
+ amount: '1250.50',
80
+ creditor_name: 'Example Supplier GmbH',
81
+ creditor_iban: 'DE89370400440532013000',
82
+ creditor_bic: 'COBADEFFXXX',
83
+ end_to_end_id: 'INV-2026-0001',
84
+ remittance_information: 'Invoice 2026-0001',
85
+ instruction_id: 'PAY-0001'
86
+ )
87
+
88
+ document = Brot::Pain00100112::Document.new(
89
+ message_id: 'MSG-20260313-01',
90
+ payment_information_id: 'PMT-20260313-01',
91
+ initiating_party_name: 'Example Debtor GmbH',
92
+ debtor_name: 'Example Debtor GmbH',
93
+ debtor_iban: 'DE12500105170648489890',
94
+ debtor_bic: 'INGDDEFFXXX',
95
+ requested_execution_date: Date.new(2026, 3, 13),
96
+ transfers: [transfer],
97
+ batch_booking: true
98
+ )
99
+
100
+ xml = document.to_xml
101
+ result = document.validate
102
+
103
+ raise result.errors.join("\n") unless result.valid?
104
+ ```
105
+
106
+ ## Public API
107
+
108
+ The main public classes are:
109
+
110
+ - `Brot::Pain00100112::Account`
111
+ - `Brot::Pain00100112::Transfer`
112
+ - `Brot::Pain00100112::Document`
113
+ - `Brot::Pain00100112::Schema`
114
+ - `Brot::Pain00100112::Schema::Result`
115
+
116
+ ### `Brot::Pain00100112::Account`
117
+
118
+ Use `Account` when you want a standalone object representing debtor or creditor
119
+ banking details. You do not need to instantiate it manually for normal usage,
120
+ because `Document` and `Transfer` build accounts from `*_iban` and `*_bic`
121
+ attributes internally.
122
+
123
+ Example:
124
+
125
+ ```ruby
126
+ account = Brot::Pain00100112::Account.new(
127
+ iban: 'DE89370400440532013000',
128
+ bic: 'COBADEFFXXX'
129
+ )
130
+
131
+ account.iban
132
+ # => "DE89370400440532013000"
133
+
134
+ account.bic
135
+ # => "COBADEFFXXX"
136
+ ```
137
+
138
+ Attributes:
139
+
140
+ - `iban`
141
+ Example: `DE89370400440532013000`
142
+ Must be a syntactically valid IBAN. Spaces are removed automatically.
143
+ - `bic`
144
+ Example: `COBADEFFXXX`
145
+ Optional. If omitted, the serializer falls back to `NOTPROVIDED` in the XML.
146
+
147
+ Useful methods:
148
+
149
+ - `bic_or_placeholder`
150
+ Returns the actual BIC if present, otherwise `NOTPROVIDED`.
151
+
152
+ ### `Brot::Pain00100112::Transfer`
153
+
154
+ Use `Transfer` for each outgoing credit transfer inside the payment batch.
155
+
156
+ Example:
157
+
158
+ ```ruby
159
+ transfer = Brot::Pain00100112::Transfer.new(
160
+ amount: '1250.50',
161
+ creditor_name: 'Example Supplier GmbH',
162
+ creditor_iban: 'DE89370400440532013000',
163
+ creditor_bic: 'COBADEFFXXX',
164
+ end_to_end_id: 'INV-2026-0001',
165
+ remittance_information: 'Invoice 2026-0001',
166
+ instruction_id: 'PAY-0001',
167
+ purpose_code: 'SUPP'
168
+ )
169
+ ```
170
+
171
+ Initialization attributes:
172
+
173
+ - `amount`
174
+ Example: `'1250.50'`
175
+ Required. Must be positive and have at most two decimal places.
176
+ - `creditor_name`
177
+ Example: `'Example Supplier GmbH'`
178
+ Required. Maximum length is 70 characters in this implementation.
179
+ - `creditor_iban`
180
+ Example: `'DE89370400440532013000'`
181
+ Required. Validated as an IBAN.
182
+ - `creditor_bic`
183
+ Example: `'COBADEFFXXX'`
184
+ Optional. Validated as a BIC if supplied.
185
+ - `end_to_end_id`
186
+ Example: `'INV-2026-0001'`
187
+ Required. Maximum length is 35 characters.
188
+ - `remittance_information`
189
+ Example: `'Invoice 2026-0001'`
190
+ Required. Maximum length is 140 characters.
191
+ - `instruction_id`
192
+ Example: `'PAY-0001'`
193
+ Optional. Maximum length is 35 characters.
194
+ - `purpose_code`
195
+ Example: `'SUPP'`
196
+ Optional. Maximum length is 4 characters.
197
+
198
+ Readable attributes after initialization:
199
+
200
+ - `amount`
201
+ - `creditor_name`
202
+ - `creditor_account`
203
+ - `end_to_end_id`
204
+ - `remittance_information`
205
+ - `instruction_id`
206
+ - `purpose_code`
207
+
208
+ ### `Brot::Pain00100112::Document`
209
+
210
+ Use `Document` to represent the full payment file. This is the main entry
211
+ point of the gem.
212
+
213
+ Example:
214
+
215
+ ```ruby
216
+ document = Brot::Pain00100112::Document.new(
217
+ message_id: 'MSG-20260313-01',
218
+ payment_information_id: 'PMT-20260313-01',
219
+ initiating_party_name: 'Example Debtor GmbH',
220
+ debtor_name: 'Example Debtor GmbH',
221
+ debtor_iban: 'DE12500105170648489890',
222
+ debtor_bic: 'INGDDEFFXXX',
223
+ requested_execution_date: Date.new(2026, 3, 13),
224
+ transfers: [transfer],
225
+ batch_booking: true,
226
+ created_at: Time.utc(2026, 3, 13, 12, 0, 0)
227
+ )
228
+ ```
229
+
230
+ Initialization attributes:
231
+
232
+ - `message_id`
233
+ Example: `'MSG-20260313-01'`
234
+ Required. Group header message identifier. Maximum length is 35 characters.
235
+ - `payment_information_id`
236
+ Example: `'PMT-20260313-01'`
237
+ Required. Payment information block identifier. Maximum length is 35
238
+ characters.
239
+ - `initiating_party_name`
240
+ Example: `'Example Debtor GmbH'`
241
+ Required. Maximum length is 70 characters.
242
+ - `debtor_name`
243
+ Example: `'Example Debtor GmbH'`
244
+ Required. Maximum length is 70 characters.
245
+ - `debtor_iban`
246
+ Example: `'DE12500105170648489890'`
247
+ Required. Validated as an IBAN.
248
+ - `debtor_bic`
249
+ Example: `'INGDDEFFXXX'`
250
+ Optional. Validated as a BIC if supplied.
251
+ - `requested_execution_date`
252
+ Example: `Date.new(2026, 3, 13)` or `'2026-03-13'`
253
+ Required. Date only.
254
+ - `transfers`
255
+ Example: `[transfer_1, transfer_2]`
256
+ Required. Must contain at least one `Brot::Pain00100112::Transfer`.
257
+ - `batch_booking`
258
+ Example: `true`
259
+ Optional. Defaults to `true`.
260
+ - `created_at`
261
+ Example: `Time.utc(2026, 3, 13, 12, 0, 0)`
262
+ Optional. Defaults to the current UTC time.
263
+
264
+ Readable attributes after initialization:
265
+
266
+ - `message_id`
267
+ - `payment_information_id`
268
+ - `initiating_party_name`
269
+ - `debtor_name`
270
+ - `debtor_account`
271
+ - `requested_execution_date`
272
+ - `transfers`
273
+ - `batch_booking`
274
+ - `created_at`
275
+
276
+ Useful methods:
277
+
278
+ - `to_xml(pretty: true)`
279
+ Serializes the document to `pain.001.001.12` XML.
280
+ - `validate(xsd_path: Brot::Pain00100112::Schema.bundled_xsd_path)`
281
+ Validates the generated XML and returns a `Schema::Result`.
282
+ - `control_sum`
283
+ Returns the sum of all transfer amounts as a `BigDecimal`.
284
+ - `number_of_transactions`
285
+ Returns the number of transfers.
286
+
287
+ ### `Brot::Pain00100112::Schema`
288
+
289
+ Use `Schema` when you want to validate XML directly without first building a
290
+ `Document` object.
291
+
292
+ Example:
293
+
294
+ ```ruby
295
+ xml = document.to_xml(pretty: false)
296
+ result = Brot::Pain00100112::Schema.validate(xml)
297
+
298
+ raise result.errors.join("\n") unless result.valid?
299
+ ```
300
+
301
+ Available methods:
302
+
303
+ - `bundled_xsd_path`
304
+ Returns the absolute path of the XSD bundled inside the gem.
305
+ - `validate(xml, xsd_path: bundled_xsd_path)`
306
+ Validates an XML string against the bundled schema by default.
307
+
308
+ ### `Brot::Pain00100112::Schema::Result`
309
+
310
+ `Schema.validate` and `Document#validate` both return a
311
+ `Brot::Pain00100112::Schema::Result`.
312
+
313
+ Example:
314
+
315
+ ```ruby
316
+ result = document.validate
317
+
318
+ result.valid?
319
+ # => true
320
+
321
+ result.errors
322
+ # => []
323
+ ```
324
+
325
+ Attributes and methods:
326
+
327
+ - `errors`
328
+ An array of schema validation error messages.
329
+ - `valid?`
330
+ Returns `true` when `errors` is empty.
331
+
332
+ ## End-To-End Example
333
+
334
+ This example shows the normal workflow from transfers to XML generation and
335
+ validation:
336
+
337
+ ```ruby
338
+ require 'brot'
339
+ require 'date'
340
+
341
+ transfers = [
342
+ Brot::Pain00100112::Transfer.new(
343
+ amount: '1250.50',
344
+ creditor_name: 'Example Supplier GmbH',
345
+ creditor_iban: 'DE89370400440532013000',
346
+ creditor_bic: 'COBADEFFXXX',
347
+ end_to_end_id: 'INV-2026-0001',
348
+ remittance_information: 'Invoice 2026-0001'
349
+ ),
350
+ Brot::Pain00100112::Transfer.new(
351
+ amount: '349.99',
352
+ creditor_name: 'Another Supplier GmbH',
353
+ creditor_iban: 'DE44500105175407324931',
354
+ end_to_end_id: 'INV-2026-0002',
355
+ remittance_information: 'Invoice 2026-0002'
356
+ )
357
+ ]
358
+
359
+ document = Brot::Pain00100112::Document.new(
360
+ message_id: 'MSG-20260313-01',
361
+ payment_information_id: 'PMT-20260313-01',
362
+ initiating_party_name: 'Example Debtor GmbH',
363
+ debtor_name: 'Example Debtor GmbH',
364
+ debtor_iban: 'DE12500105170648489890',
365
+ debtor_bic: 'INGDDEFFXXX',
366
+ requested_execution_date: Date.new(2026, 3, 13),
367
+ transfers: transfers
368
+ )
369
+
370
+ xml = document.to_xml
371
+ result = document.validate
372
+
373
+ abort(result.errors.join("\n")) unless result.valid?
374
+
375
+ puts xml
376
+ ```
377
+
378
+ ## Validation
379
+
380
+ The gem ships with the `pain.001.001.12.xsd` file, so validation works without
381
+ an external path:
382
+
383
+ ```ruby
384
+ result = document.validate
385
+
386
+ raise result.errors.join("\n") unless result.valid?
387
+ ```
388
+
389
+ If you want to override the schema file, pass `xsd_path:` explicitly.
390
+
391
+ ## Error Handling
392
+
393
+ Invalid input values raise `Brot::Pain00100112::ValidationError`.
394
+
395
+ Typical examples:
396
+
397
+ - Invalid IBAN
398
+ - Invalid BIC
399
+ - Empty required text fields
400
+ - Amounts that are zero, negative, or have too many decimal places
401
+ - Missing transfers
402
+
403
+ Example:
404
+
405
+ ```ruby
406
+ begin
407
+ Brot::Pain00100112::Transfer.new(
408
+ amount: '0',
409
+ creditor_name: 'Broken Example',
410
+ creditor_iban: 'INVALID',
411
+ end_to_end_id: 'X',
412
+ remittance_information: 'Broken'
413
+ )
414
+ rescue Brot::Pain00100112::ValidationError => error
415
+ warn error.message
416
+ end
417
+ ```
418
+
419
+ ## Development
420
+
421
+ ```sh
422
+ bundle install
423
+ bundle exec rake
424
+ ```
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'minitest/test_task'
5
+ require 'rubocop/rake_task'
6
+
7
+ Minitest::TestTask.create
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[test rubocop]
data/brot.gemspec ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/brot/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'brot'
7
+ spec.version = Brot::VERSION
8
+ spec.authors = ['Vincent Garrigues']
9
+ spec.email = ['vincent@garriguv.io']
10
+
11
+ spec.summary = 'Build ISO 20022 pain.001 payment initiation XML for DATEV uploads.'
12
+ spec.description = <<~TEXT
13
+ brot builds SEPA credit transfer XML with Nokogiri::XML::Builder.
14
+ The output targets pain.001.001.12 for DATEV-oriented uploads.
15
+ TEXT
16
+ spec.homepage = 'https://github.com/garriguv/brot'
17
+ spec.license = 'MIT'
18
+ spec.required_ruby_version = '>= 4.0.0'
19
+
20
+ spec.metadata['homepage_uri'] = spec.homepage
21
+ spec.metadata['source_code_uri'] = spec.homepage
22
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/releases"
23
+ spec.metadata['rubygems_mfa_required'] = 'true'
24
+
25
+ spec.files = Dir.glob(
26
+ [
27
+ '.github/workflows/*.yml',
28
+ '.rubocop.yml',
29
+ 'Gemfile',
30
+ 'LICENSE.txt',
31
+ 'README.md',
32
+ 'Rakefile',
33
+ 'brot.gemspec',
34
+ 'lib/**/*.rb',
35
+ 'test/**/*.rb',
36
+ 'xsd/**/*.xsd'
37
+ ]
38
+ )
39
+ spec.bindir = 'exe'
40
+ spec.executables = []
41
+ spec.require_paths = ['lib']
42
+
43
+ spec.add_dependency 'nokogiri', '~> 1.18'
44
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brot
4
+ module Pain00100112
5
+ # Bank account details used in debtor and creditor sections.
6
+ #
7
+ # You will usually not instantiate {Account} directly because {Document}
8
+ # and {Transfer} build account objects internally from `*_iban` and
9
+ # `*_bic` keyword arguments.
10
+ #
11
+ # @example Build an account manually
12
+ # account = Brot::Pain00100112::Account.new(
13
+ # iban: 'DE89370400440532013000',
14
+ # bic: 'COBADEFFXXX'
15
+ # )
16
+ #
17
+ # account.iban
18
+ # # => "DE89370400440532013000"
19
+ #
20
+ # @attr_reader iban
21
+ # The normalized IBAN.
22
+ # Example: `DE89370400440532013000`
23
+ # @attr_reader bic
24
+ # The normalized BIC, or `nil` if none was provided.
25
+ # Example: `COBADEFFXXX`
26
+ class Account
27
+ attr_reader :bic, :iban
28
+
29
+ # @param iban [String]
30
+ # Required IBAN.
31
+ # Example: `DE89370400440532013000`
32
+ # @param bic [String, nil]
33
+ # Optional BIC.
34
+ # Example: `COBADEFFXXX`
35
+ def initialize(iban:, bic: nil)
36
+ @iban = Utils.normalize_iban!(iban)
37
+ @bic = bic.nil? ? nil : Utils.normalize_bic!(bic)
38
+ end
39
+
40
+ # Returns the BIC if present, otherwise `NOTPROVIDED`.
41
+ #
42
+ # @return [String]
43
+ def bic_or_placeholder
44
+ bic || Utils.default_financial_institution_id
45
+ end
46
+ end
47
+ end
48
+ end