facturx 0.1.0 → 1.0.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -2
  3. data/NOTICE.md +2 -0
  4. data/README.md +62 -4
  5. data/lib/facturx/builders/association.rb +8 -0
  6. data/lib/facturx/builders/base.rb +129 -0
  7. data/lib/facturx/builders/schema.rb +112 -0
  8. data/lib/facturx/builders.rb +25 -0
  9. data/lib/facturx/coerce.rb +74 -0
  10. data/lib/facturx/conformance.rb +163 -0
  11. data/lib/facturx/diagnostic.rb +20 -0
  12. data/lib/facturx/document.rb +45 -0
  13. data/lib/facturx/error.rb +3 -0
  14. data/lib/facturx/format.rb +85 -0
  15. data/lib/facturx/generate.rb +21 -0
  16. data/lib/facturx/group.rb +5 -0
  17. data/lib/facturx/model/common.rb +8 -0
  18. data/lib/facturx/model/immutable.rb +75 -0
  19. data/lib/facturx/model/line.rb +14 -0
  20. data/lib/facturx/model/party.rb +10 -0
  21. data/lib/facturx/model/payment.rb +9 -0
  22. data/lib/facturx/model/reference.rb +7 -0
  23. data/lib/facturx/model/trade.rb +9 -0
  24. data/lib/facturx/model.rb +9 -0
  25. data/lib/facturx/pdf/extractor.rb +7 -4
  26. data/lib/facturx/profile_resolver.rb +40 -0
  27. data/lib/facturx/reader/semantic_mapper/delivery_mapping.rb +43 -0
  28. data/lib/facturx/reader/semantic_mapper/document_mapping.rb +109 -0
  29. data/lib/facturx/reader/semantic_mapper/helpers.rb +114 -0
  30. data/lib/facturx/reader/semantic_mapper/line_mapping.rb +120 -0
  31. data/lib/facturx/reader/semantic_mapper/line_references.rb +19 -0
  32. data/lib/facturx/reader/semantic_mapper/party_mapping.rb +90 -0
  33. data/lib/facturx/reader/semantic_mapper/payment_mapping.rb +108 -0
  34. data/lib/facturx/reader/semantic_mapper/trade_mapping.rb +83 -0
  35. data/lib/facturx/reader/semantic_mapper.rb +76 -0
  36. data/lib/facturx/reader/term_reader/coercion.rb +69 -0
  37. data/lib/facturx/reader/term_reader/unmapped.rb +37 -0
  38. data/lib/facturx/reader/term_reader.rb +132 -0
  39. data/lib/facturx/reader.rb +97 -0
  40. data/lib/facturx/reading.rb +23 -0
  41. data/lib/facturx/source_reader.rb +30 -0
  42. data/lib/facturx/term.rb +5 -0
  43. data/lib/facturx/terms/declaration.rb +20 -0
  44. data/lib/facturx/terms/declarations/adjustments.rb +230 -0
  45. data/lib/facturx/terms/declarations/delivery.rb +83 -0
  46. data/lib/facturx/terms/declarations/document.rb +223 -0
  47. data/lib/facturx/terms/declarations/groups.rb +356 -0
  48. data/lib/facturx/terms/declarations/lines.rb +166 -0
  49. data/lib/facturx/terms/declarations/parties.rb +609 -0
  50. data/lib/facturx/terms/declarations/payment.rb +106 -0
  51. data/lib/facturx/terms/declarations/products.rb +103 -0
  52. data/lib/facturx/terms/declarations/references.rb +72 -0
  53. data/lib/facturx/terms/declarations/taxes.rb +92 -0
  54. data/lib/facturx/terms/declarations/totals.rb +107 -0
  55. data/lib/facturx/terms/reference.rb +21 -0
  56. data/lib/facturx/terms.rb +109 -0
  57. data/lib/facturx/version.rb +1 -1
  58. data/lib/facturx/writer/context.rb +53 -0
  59. data/lib/facturx/writer/stage.rb +47 -0
  60. data/lib/facturx/writer/stage_support/contact_details.rb +57 -0
  61. data/lib/facturx/writer/stage_support/emission.rb +78 -0
  62. data/lib/facturx/writer/stage_support/identifiers.rb +129 -0
  63. data/lib/facturx/writer/stage_support/structure.rb +88 -0
  64. data/lib/facturx/writer/stages/document_context.rb +31 -0
  65. data/lib/facturx/writer/stages/exchanged_document.rb +32 -0
  66. data/lib/facturx/writer/stages/trade_agreement/parties.rb +105 -0
  67. data/lib/facturx/writer/stages/trade_agreement/references.rb +92 -0
  68. data/lib/facturx/writer/stages/trade_agreement.rb +45 -0
  69. data/lib/facturx/writer/stages/trade_delivery.rb +77 -0
  70. data/lib/facturx/writer/stages/trade_lines/agreement.rb +92 -0
  71. data/lib/facturx/writer/stages/trade_lines/product.rb +78 -0
  72. data/lib/facturx/writer/stages/trade_lines/quantity.rb +17 -0
  73. data/lib/facturx/writer/stages/trade_lines/settlement.rb +89 -0
  74. data/lib/facturx/writer/stages/trade_lines.rb +67 -0
  75. data/lib/facturx/writer/stages/trade_settlement/parties.rb +33 -0
  76. data/lib/facturx/writer/stages/trade_settlement/payment.rb +97 -0
  77. data/lib/facturx/writer/stages/trade_settlement/summary.rb +88 -0
  78. data/lib/facturx/writer/stages/trade_settlement/taxes.rb +121 -0
  79. data/lib/facturx/writer/stages/trade_settlement.rb +70 -0
  80. data/lib/facturx/writer.rb +68 -0
  81. data/lib/facturx/xml/namespaces.rb +19 -0
  82. data/lib/facturx/xml/profile_detector.rb +2 -4
  83. data/lib/facturx.rb +42 -0
  84. data/rbi/facturx.rbi +1090 -0
  85. metadata +79 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61ac3e80ab256d20b48527e6b92bccd30fcc6f0b7d1b87dec452161fd9e916fb
4
- data.tar.gz: 35227d6b843d4c7f51608c2d149b4ce456ca854b16a14ed11317dab0ba2f2cfe
3
+ metadata.gz: 10cd4d86db6d643606dbd85da9ced31d20e884249505e8a608ebb864f9a2f2ef
4
+ data.tar.gz: 3e0665c807043cdca83550452ab2b1e3ca7d40b759105eea8856916a316576fd
5
5
  SHA512:
6
- metadata.gz: aa5602b1058a4e5cd06821ea7995d761983b9ddf6cb736e3bcc46e8473230baaf6f215b8f78034f633f19223072b24ce6e474466f5a534801e89516e6d949854
7
- data.tar.gz: 3f9504f7f0c04245bd50948d38a1608f1accc18c5bda8dc953229ef60ac4e7cd1071aa15bef81172007e41d6c4eae76cbeaa4999d67d2fb80e52fe9dd00db4da
6
+ metadata.gz: 0cf6cb2b75adb6a668a5746420ee82177aada0c87d9d40bd625387a59d1409b63ef53916df3480aa7adaf539b59c99fb635667134e3cefcbd66590f1a19aeecd
7
+ data.tar.gz: 3e7715e0181e11b9b0589ab85da8194e692647fcb4be0d42d177968584d0cc1f1e2114a782e8d533c7c5a2fbac0943bc2bd4964e59633773c9dd864e5a42cee4
data/CHANGELOG.md CHANGED
@@ -4,13 +4,40 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.0.0] - 2026-09-13
8
+
9
+ ### Add
10
+
11
+ - Add immutable invoice construction through a typed nested DSL.
12
+ - Add aggregated document conformance validation for all five built-in profiles.
13
+ - Add profile-specific Factur-X XML generation and PDF generation from typed documents.
14
+ - Add strict RBI signatures for the public models, builders, reports, errors, and facades.
15
+ - Add maintainer verification of term membership and cardinalities against pinned official workbooks.
16
+
17
+ ### Fix
18
+
19
+ - Fix partial or unrepresentable values so supplied data is rejected instead of silently discarded.
20
+ - Fix repeated credit transfers and discriminated references across semantic round trips.
21
+ - Fix MINIMUM accounting-currency tax totals without emitting the forbidden tax currency term.
22
+ - Fix invalid profile objects to raise the documented typed error.
23
+
24
+ ## [0.2.0] - 2026-09-12
25
+
26
+ ### Add
27
+
28
+ - Read XML or PDF bytes into an immutable, typed EN16931 document model with structured diagnostics.
29
+ - Map the complete EN16931 semantic term set and the MINIMUM, BASIC WL, and BASIC subsets.
30
+ - Verify semantic mapping provenance and official examples with the maintainer reference task.
31
+
7
32
  ## [0.1.0] - 2026-09-11
8
33
 
9
- ### Added
34
+ ### Add
10
35
 
11
36
  - Validate Factur-X 1.09.2 XML against its profile XSD.
12
37
  - Compose PDF/A-3b Factur-X documents with Ghostscript.
13
38
  - Extract embedded Factur-X XML from PDF documents.
14
39
 
15
- [Unreleased]: https://github.com/AdVitam/facturx/compare/v0.1.0...HEAD
40
+ [Unreleased]: https://github.com/AdVitam/facturx/compare/v1.0.0...HEAD
41
+ [1.0.0]: https://github.com/AdVitam/facturx/compare/v0.2.0...v1.0.0
42
+ [0.2.0]: https://github.com/AdVitam/facturx/compare/v0.1.0...v0.2.0
16
43
  [0.1.0]: https://github.com/AdVitam/facturx/releases/tag/v0.1.0
data/NOTICE.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  The gem includes Factur-X 1.09.2 / ZUGFeRD 2.5.2 XSD validation artefacts. Their Apache License 2.0 text, provenance, RubyGems packaging adaptation, and file checksums are packaged under `lib/facturx/schema/`.
6
6
 
7
+ The semantic term registry is derived from the official Factur-X 1.09.2 profile workbooks. The workbooks and examples are not redistributed; their expected paths and SHA-256 checksums are recorded in `Facturx::Terms::REFERENCE_FILES` for maintainer verification.
8
+
7
9
  ## Ghostscript
8
10
 
9
11
  PDF composition invokes a Ghostscript installation supplied by the user. Ghostscript, `zugferd.ps`, and ICC profiles are not part of this gem and remain governed by their respective licences.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # facturx
2
2
 
3
- `facturx` validates Factur-X XML, creates PDF/A-3b Factur-X invoices from existing PDFs, and extracts their embedded XML.
3
+ `facturx` builds, reads, and validates Factur-X XML, creates PDF/A-3b Factur-X invoices from existing PDFs, and extracts their embedded XML.
4
4
 
5
5
  The first release supports Factur-X 1.09.2 / ZUGFeRD 2.5.2 with the MINIMUM, BASIC WL, BASIC, EN 16931, and EXTENDED profiles.
6
6
 
@@ -26,20 +26,72 @@ require 'facturx'
26
26
  xml = File.binread('invoice.xml')
27
27
  pdf = File.binread('invoice.pdf')
28
28
 
29
- Facturx.verify_xml(xml: xml) # => true
29
+ Facturx.verify_xml(xml: xml)
30
+
31
+ reading = Facturx.read(xml)
32
+ reading.document.invoice_number
33
+ reading.document.totals.grand_total
34
+ reading.diagnostics
30
35
 
31
36
  facturx_pdf = Facturx.attach(pdf: pdf, xml: xml)
32
37
  File.binwrite('invoice-facturx.pdf', facturx_pdf)
33
38
 
34
39
  embedded_xml = Facturx.extract_xml(pdf: facturx_pdf)
40
+ pdf_reading = Facturx.read(facturx_pdf)
41
+ ```
42
+
43
+ Build a typed document with the nested DSL, validate it without generating XML, or generate XML and attach it to an existing PDF:
44
+
45
+ ```ruby
46
+ document = Facturx::Document.build(
47
+ invoice_number: 'INV-2026-0042',
48
+ type_code: '380',
49
+ issue_date: Date.new(2026, 9, 12),
50
+ currency: 'EUR'
51
+ ) do |invoice|
52
+ invoice.seller do |seller|
53
+ seller.name = 'Seller SAS'
54
+ seller.address(country_code: 'FR')
55
+ end
56
+ invoice.buyer(name: 'Buyer SAS')
57
+ invoice.totals(
58
+ tax_basis_total: BigDecimal('100.00'),
59
+ grand_total: BigDecimal('120.00'),
60
+ due_payable: BigDecimal('120.00')
61
+ )
62
+ end
63
+
64
+ report = Facturx.validate_document(document:, profile: :minimum)
65
+ report.valid?
66
+
67
+ xml = Facturx.build_xml(document:, profile: :minimum)
68
+ facturx_pdf = Facturx.generate(pdf:, document:, profile: :minimum)
35
69
  ```
36
70
 
71
+ `profile:` accepts one of `:minimum`, `:basic_wl`, `:basic`, `:en16931`, or `:extended`, as well as the corresponding canonical `Facturx::Profile`. When BT-24 is absent, the writer inserts the selected profile's guideline URN. A conflicting BT-24 is reported as a profile mismatch.
72
+
73
+ `validate_document` returns an immutable report containing every conformance issue found. `build_xml` and `generate` raise `Facturx::ConformanceError` with that report when the document is invalid. Values are serialized from their declared semantic type; monetary values with more than two decimal places are rejected instead of being rounded implicitly. Generated XML is always checked against the selected profile's XSD.
74
+
37
75
  `verify_xml` infers the profile from BT-24 and validates the document against that profile's XSD. It returns `true` on success and raises a typed `Facturx::Error` on failure.
38
76
 
39
77
  `attach` always validates the XML first. It converts the input PDF to PDF/A-3b, embeds the original XML bytes as `factur-x.xml`, and verifies the resulting attachment and metadata. Signed and encrypted PDFs are rejected because rewriting them would invalidate their protection.
40
78
 
41
79
  `extract_xml` returns the embedded bytes without validating them. This allows callers to inspect non-conforming invoices received from third parties and explicitly call `verify_xml` when appropriate.
42
80
 
81
+ `read` accepts either XML or PDF bytes and returns an immutable `Facturx::Reading`. Its `document` contains typed immutable values: dates are `Date`, decimals are `BigDecimal`, identifiers retain their schemes, and repeating groups are frozen arrays in XML order. `source` always contains the exact embedded XML bytes and `source_type` is either `:xml` or `:pdf`.
82
+
83
+ Reading is intentionally tolerant and never performs implicit XSD validation. Missing, duplicate, empty, invalid, or unmapped values are reported through immutable diagnostics while usable fields remain accessible. Call `verify_xml` separately when strict structural validation is required.
84
+
85
+ Unknown or missing BT-24 values fall back to the EN16931 intersection supported for EXTENDED documents and add a diagnostic. Pass `on_unknown_profile: :raise` to reject them instead:
86
+
87
+ ```ruby
88
+ Facturx.read(xml, on_unknown_profile: :raise)
89
+ ```
90
+
91
+ The semantic registry covers all 184 EN16931 business terms and the MINIMUM, BASIC WL, and BASIC subsets. EXTENDED-only fields remain available in `Reading#source` and are reported as unmapped until their model is added.
92
+
93
+ Reading is not a fidelity round-trip. To reissue an incoming invoice, retain and reuse `Reading#source`; the writer cannot reproduce fields outside the semantic model.
94
+
43
95
  The byte-string API materializes PDF streams in memory. Process untrusted PDFs in a resource-limited worker; limiting only the input file size does not prevent amplification by a compressed embedded stream.
44
96
 
45
97
  ## Ghostscript discovery
@@ -56,9 +108,9 @@ The gem invokes Ghostscript as an external process. It does not distribute Ghost
56
108
 
57
109
  ## Validation scope
58
110
 
59
- Version 0.1 validates XML structure with the official XSDs. It does not yet run the Factur-X Schematron business rules. XSD validation alone must not be presented as complete semantic or regulatory validation.
111
+ The gem validates generated documents against its EN16931 semantic registry and the official profile XSDs. It does not yet run the Factur-X Schematron business rules, calculate totals, or evaluate accounting consistency. These checks must not be presented as complete regulatory validation.
60
112
 
61
- The gem does not generate invoice XML or the visual invoice PDF, communicate with a PDP, or implement e-reporting.
113
+ The gem does not generate the visual invoice PDF, communicate with a PDP, or implement e-reporting. EXTENDED-only fields are not generated until they are represented in the typed model.
62
114
 
63
115
  ## Development
64
116
 
@@ -70,6 +122,12 @@ bundle exec rspec
70
122
  bundle exec rake build
71
123
  ```
72
124
 
125
+ Maintainers can verify the registry hashes, D22B element names, semantic diagnostics, official XML examples, and paired PDF attachments against an extracted upstream package:
126
+
127
+ ```bash
128
+ FACTURX_REFERENCE_ROOT=/path/to/ZUGFeRD_2.5.2_EN bundle exec rake reference:verify
129
+ ```
130
+
73
131
  ## License
74
132
 
75
133
  The gem code is available under the MIT License. Bundled standard artefacts retain their respective upstream notices; see `NOTICE.md`.
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ module Builders
5
+ Association = Data.define(:model, :collection, :helper)
6
+ private_constant :Association
7
+ end
8
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ module Builders
5
+ class Base
6
+ UNSET = Object.new.freeze
7
+ private_constant :UNSET
8
+
9
+ class << self
10
+ attr_reader :model, :associations
11
+
12
+ def configure(model, associations)
13
+ @model = model
14
+ @associations = associations
15
+ define_attribute_methods
16
+ self
17
+ end
18
+
19
+ def build(**attributes, &block)
20
+ builder = new(**attributes)
21
+ block&.call(builder)
22
+ builder.build
23
+ end
24
+
25
+ private
26
+
27
+ def define_attribute_methods
28
+ model.members.each do |attribute|
29
+ association = associations[attribute]
30
+ association ? define_association_methods(attribute, association) : define_scalar_methods(attribute)
31
+ end
32
+ end
33
+
34
+ def define_scalar_methods(attribute)
35
+ define_method(attribute) { @attributes[attribute] }
36
+ define_method(:"#{attribute}=") { |value| @attributes[attribute] = value }
37
+ end
38
+
39
+ def define_association_methods(attribute, association)
40
+ if association.collection
41
+ define_collection_helper(attribute, association)
42
+ else
43
+ define_singular_helper(attribute, association)
44
+ end
45
+ define_method(:"#{attribute}=") { |value| set_association(attribute, association, value) }
46
+ end
47
+
48
+ def define_singular_helper(attribute, association)
49
+ define_method(attribute) do |value = UNSET, **attributes, &block|
50
+ assign_association(attribute, association, value, attributes, block)
51
+ end
52
+ end
53
+
54
+ def define_collection_helper(attribute, association)
55
+ define_method(association.helper) do |value = UNSET, **attributes, &block|
56
+ append_association(attribute, association, value, attributes, block)
57
+ end
58
+ define_method(attribute) { @attributes.fetch(attribute, []).dup.freeze }
59
+ end
60
+ end
61
+
62
+ def initialize(**attributes)
63
+ unknown = attributes.keys - self.class.model.members
64
+ raise ArgumentError, "Unknown attributes: #{unknown.map(&:inspect).join(', ')}" unless unknown.empty?
65
+
66
+ @attributes = {}
67
+ attributes.each { |attribute, value| public_send(:"#{attribute}=", value) }
68
+ end
69
+
70
+ def build
71
+ self.class.model.new(**@attributes)
72
+ end
73
+
74
+ private
75
+
76
+ def assign_association(attribute, association, value, attributes, block)
77
+ if @attributes.key?(attribute)
78
+ raise ArgumentError,
79
+ "#{attribute} is already set; use #{attribute}= to replace it"
80
+ end
81
+
82
+ object = association_value(attribute, association, value, attributes, block)
83
+ @attributes[attribute] = object
84
+ end
85
+
86
+ def append_association(attribute, association, value, attributes, block)
87
+ object = association_value(association.helper, association, value, attributes, block)
88
+ (@attributes[attribute] ||= []) << object
89
+ object
90
+ end
91
+
92
+ def set_association(attribute, association, value)
93
+ if association.collection
94
+ set_collection(attribute, association, value)
95
+ else
96
+ validate_object!(attribute, association.model, value) unless value.nil?
97
+ @attributes[attribute] = value
98
+ end
99
+ end
100
+
101
+ def set_collection(attribute, association, value)
102
+ raise TypeError, "#{attribute} must be an Array" unless value.is_a?(Array)
103
+
104
+ value.each { |object| validate_object!(attribute, association.model, object) }
105
+ @attributes[attribute] = value.dup
106
+ end
107
+
108
+ def association_value(name, association, value, attributes, block)
109
+ forms = [!value.equal?(UNSET), !attributes.empty?, !block.nil?]
110
+ unless forms.one?
111
+ raise ArgumentError, "#{name} accepts exactly one of an object, keyword attributes, or a block"
112
+ end
113
+
114
+ return validate_object!(name, association.model, value) unless value.equal?(UNSET)
115
+
116
+ builder = Builders.for(association.model)
117
+ attributes.empty? ? builder.build(&block) : builder.build(**attributes)
118
+ end
119
+
120
+ def validate_object!(attribute, model, object)
121
+ unless object.instance_of?(model) && object.frozen?
122
+ raise TypeError, "#{attribute} must be an immutable #{model.name}"
123
+ end
124
+
125
+ object
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'association'
4
+
5
+ module Facturx
6
+ module Builders
7
+ module Schema
8
+ module_function
9
+
10
+ def singular(model)
11
+ Association.new(model:, collection: false, helper: nil)
12
+ end
13
+
14
+ def collection(model, helper)
15
+ Association.new(model:, collection: true, helper:)
16
+ end
17
+
18
+ ASSOCIATIONS = {
19
+ Note => {},
20
+ Identifier => {},
21
+ Period => {},
22
+ Quantity => {},
23
+ Contact => {},
24
+ Address => {},
25
+ Party => {
26
+ identifiers: collection(Identifier, :identifier),
27
+ legal_registration: singular(Identifier),
28
+ address: singular(Address),
29
+ vat_identifier: singular(Identifier),
30
+ tax_identifier: singular(Identifier),
31
+ electronic_address: singular(Identifier),
32
+ contact: singular(Contact)
33
+ },
34
+ CreditTransfer => {
35
+ account_identifier: singular(Identifier),
36
+ provider_identifier: singular(Identifier)
37
+ },
38
+ PaymentCard => {},
39
+ DirectDebit => {
40
+ mandate_identifier: singular(Identifier),
41
+ creditor_identifier: singular(Identifier),
42
+ debtor_account_identifier: singular(Identifier)
43
+ },
44
+ PaymentInstructions => {
45
+ credit_transfers: collection(CreditTransfer, :credit_transfer),
46
+ payment_card: singular(PaymentCard),
47
+ direct_debit: singular(DirectDebit)
48
+ },
49
+ DocumentReference => {},
50
+ Delivery => {
51
+ location_identifier: singular(Identifier),
52
+ party: singular(Party)
53
+ },
54
+ SupportingDocument => {},
55
+ ProductAttribute => {},
56
+ ProductClassification => {},
57
+ Product => {
58
+ seller_identifier: singular(Identifier),
59
+ buyer_identifier: singular(Identifier),
60
+ global_identifier: singular(Identifier),
61
+ attributes: collection(ProductAttribute, :attribute),
62
+ classifications: collection(ProductClassification, :classification)
63
+ },
64
+ Price => {
65
+ basis_quantity: singular(Quantity)
66
+ },
67
+ TaxBreakdown => {},
68
+ AllowanceCharge => {
69
+ tax: singular(TaxBreakdown)
70
+ },
71
+ Totals => {},
72
+ Line => {
73
+ product: singular(Product),
74
+ quantity: singular(Quantity),
75
+ gross_price: singular(Price),
76
+ net_price: singular(Price),
77
+ tax: singular(TaxBreakdown),
78
+ period: singular(Period),
79
+ allowances: collection(AllowanceCharge, :allowance),
80
+ charges: collection(AllowanceCharge, :charge),
81
+ buyer_order_reference: singular(DocumentReference),
82
+ invoiced_object_identifier: singular(Identifier)
83
+ },
84
+ Document => {
85
+ project_reference: singular(DocumentReference),
86
+ contract_reference: singular(DocumentReference),
87
+ purchase_order_reference: singular(DocumentReference),
88
+ sales_order_reference: singular(DocumentReference),
89
+ receiving_advice_reference: singular(DocumentReference),
90
+ despatch_advice_reference: singular(DocumentReference),
91
+ tender_or_lot_reference: singular(DocumentReference),
92
+ invoiced_object_identifier: singular(Identifier),
93
+ seller: singular(Party),
94
+ buyer: singular(Party),
95
+ payee: singular(Party),
96
+ tax_representative: singular(Party),
97
+ delivery: singular(Delivery),
98
+ billing_period: singular(Period),
99
+ payment: singular(PaymentInstructions),
100
+ totals: singular(Totals),
101
+ notes: collection(Note, :note),
102
+ lines: collection(Line, :line),
103
+ tax_breakdowns: collection(TaxBreakdown, :tax_breakdown),
104
+ allowances: collection(AllowanceCharge, :allowance),
105
+ charges: collection(AllowanceCharge, :charge),
106
+ preceding_invoices: collection(DocumentReference, :preceding_invoice),
107
+ supporting_documents: collection(SupportingDocument, :supporting_document)
108
+ }
109
+ }.transform_values(&:freeze).freeze
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'document'
4
+ require_relative 'builders/schema'
5
+ require_relative 'builders/base'
6
+
7
+ module Facturx
8
+ module Builders
9
+ BUILDERS = Schema::ASSOCIATIONS.to_h do |model, associations|
10
+ name = :"#{model.name.delete_prefix('Facturx::')}Builder"
11
+ [model, const_set(name, Class.new(Base).configure(model, associations))]
12
+ end.freeze
13
+ private_constant :BUILDERS
14
+
15
+ module_function
16
+
17
+ def for(model)
18
+ BUILDERS.fetch(model)
19
+ end
20
+ end
21
+
22
+ Document.define_singleton_method(:build) do |**attributes, &block|
23
+ Builders::DocumentBuilder.build(**attributes, &block)
24
+ end
25
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+ require 'date'
5
+ require_relative 'error'
6
+
7
+ module Facturx
8
+ class CoercionError < Error; end
9
+
10
+ module Coerce
11
+ CONVERTERS = {
12
+ string: :string,
13
+ date_102: :formatted_date,
14
+ decimal: :decimal,
15
+ boolean: :boolean,
16
+ binary: :binary
17
+ }.freeze
18
+
19
+ module_function
20
+
21
+ def call(value, type:, scale: nil, **context)
22
+ return if value.nil?
23
+
24
+ converter = CONVERTERS[type]
25
+ raise ArgumentError, "Unsupported coercion type: #{type.inspect}" unless converter
26
+
27
+ begin
28
+ public_send(converter, value, context)
29
+ rescue ArgumentError, TypeError => e
30
+ raise CoercionError.new('Value cannot be coerced', value:, type:, scale:, **context), cause: e
31
+ end
32
+ end
33
+
34
+ def string(value, _context = nil)
35
+ raise TypeError, 'expected a String' unless value.is_a?(String)
36
+
37
+ value.dup.freeze
38
+ end
39
+
40
+ def decimal(value, _context = nil)
41
+ raise TypeError, 'Float values are not supported' if value.is_a?(Float)
42
+ return value if value.is_a?(BigDecimal)
43
+
44
+ decimal = BigDecimal(value.to_s, exception: true)
45
+ raise ArgumentError, 'decimal must be finite' unless decimal.finite?
46
+
47
+ decimal
48
+ end
49
+
50
+ def formatted_date(value, _context = nil)
51
+ return value.dup.freeze if value.is_a?(Date)
52
+
53
+ text = string(value)
54
+ raise ArgumentError, 'date must use format 102' unless text.match?(/\A\d{8}\z/)
55
+
56
+ Date.strptime(text, '%Y%m%d').freeze
57
+ end
58
+
59
+ def boolean(value, _context = nil)
60
+ return value if [true, false].include?(value)
61
+
62
+ case string(value)
63
+ when 'true', '1' then true
64
+ when 'false', '0' then false
65
+ else raise ArgumentError, 'boolean must be true, false, 1, or 0'
66
+ end
67
+ end
68
+
69
+ def binary(value, _context = nil)
70
+ encoded = string(value).delete(" \t\r\n")
71
+ encoded.unpack1('m0').force_encoding(Encoding::BINARY).freeze
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error'
4
+ require_relative 'model/immutable'
5
+
6
+ module Facturx
7
+ module Conformance
8
+ Issue = Data.define(:code, :message, :term_id, :group_id, :path, :details) do
9
+ include Model::ValidatedWith
10
+
11
+ def initialize(**attributes)
12
+ Model.validate_attributes!(self.class.members, attributes)
13
+ attributes.fetch_values(:code, :message)
14
+ values = { term_id: nil, group_id: nil, path: nil, details: {} }.merge(attributes)
15
+ raise TypeError, 'details must be a Hash' unless values[:details].is_a?(Hash)
16
+
17
+ super(**values.transform_values { |value| Model.copy_and_freeze(value) })
18
+ end
19
+ end
20
+
21
+ Report = Data.define(:profile, :issues) do
22
+ include Model::ValidatedWith
23
+
24
+ def initialize(profile:, issues: [])
25
+ raise TypeError, 'issues must be an Array' unless issues.is_a?(Array)
26
+
27
+ super(profile: Model.copy_and_freeze(profile), issues: Model.copy_and_freeze(issues))
28
+ end
29
+
30
+ def valid? = issues.empty?
31
+
32
+ def invalid? = !valid?
33
+ end
34
+
35
+ module Tracking
36
+ private
37
+
38
+ def definition_observed?(definition, count, path)
39
+ cardinality = definition.cardinalities[profile.id]
40
+ return forbidden?(definition, count, path) unless cardinality
41
+
42
+ missing(definition, path, cardinality) if count.zero? && required?(cardinality)
43
+ invalid_cardinality(definition, count, path, cardinality) if count > 1 && !repeated?(cardinality)
44
+ count.positive?
45
+ end
46
+
47
+ def forbidden?(definition, count, path)
48
+ return false if count.zero?
49
+
50
+ kind = definition_kind(definition)
51
+ add(
52
+ :"forbidden_#{kind}",
53
+ "#{kind.capitalize} is forbidden by the selected profile",
54
+ **definition_context(definition, path),
55
+ details: { profile: profile.id, count: }
56
+ )
57
+ false
58
+ end
59
+
60
+ def missing(definition, path, cardinality)
61
+ kind = definition_kind(definition)
62
+ add(
63
+ :"missing_required_#{kind}",
64
+ "Required #{kind} is missing",
65
+ **definition_context(definition, path),
66
+ details: { profile: profile.id, cardinality: }
67
+ )
68
+ end
69
+
70
+ def invalid_cardinality(definition, count, path, cardinality)
71
+ add(
72
+ :invalid_cardinality,
73
+ 'Value count exceeds the profile cardinality',
74
+ **definition_context(definition, path),
75
+ details: { profile: profile.id, cardinality:, count: }
76
+ )
77
+ end
78
+
79
+ def add(code, message, **context)
80
+ @issues << Issue.new(code:, message:, **context)
81
+ end
82
+
83
+ def definition_context(definition, path)
84
+ if definition.respond_to?(:group_id)
85
+ { term_id: definition.id, group_id: definition.group_id, path: path || definition.xpath }
86
+ else
87
+ { group_id: definition.id, path: path || definition.xpath }
88
+ end
89
+ end
90
+
91
+ def definition_kind(definition)
92
+ definition.respond_to?(:group_id) ? 'term' : 'group'
93
+ end
94
+
95
+ def required?(cardinality) = cardinality.start_with?('1')
96
+
97
+ def repeated?(cardinality) = cardinality.end_with?('n')
98
+ end
99
+ private_constant :Tracking
100
+
101
+ class Tracker
102
+ include Tracking
103
+
104
+ attr_reader :profile
105
+
106
+ def initialize(profile:)
107
+ @profile = profile
108
+ @issues = []
109
+ end
110
+
111
+ def check_guideline(guideline_urn, path: nil)
112
+ return if guideline_urn.nil? || guideline_urn == profile.guideline_urn
113
+
114
+ add(
115
+ :profile_mismatch,
116
+ 'Document guideline does not match the selected profile',
117
+ term_id: 'BT-24',
118
+ path:,
119
+ details: { expected: profile.guideline_urn, actual: guideline_urn }
120
+ )
121
+ end
122
+
123
+ def check_document(document, path: nil)
124
+ check_guideline(document.guideline_urn, path:)
125
+ end
126
+
127
+ def observe_group?(group, count:, path: nil)
128
+ definition_observed?(group, count, path)
129
+ end
130
+
131
+ def observe_term?(term, values:, path: nil)
132
+ definition_observed?(term, values.size, path)
133
+ end
134
+
135
+ def invalid_term(term, error:, path: nil)
136
+ raise TypeError, 'error must be a FormattingError' unless error.is_a?(FormattingError)
137
+
138
+ add(
139
+ :invalid_value,
140
+ error.message,
141
+ term_id: term.id,
142
+ group_id: term.group_id,
143
+ path: path || error.details[:path] || term.xpath,
144
+ details: error.details
145
+ )
146
+ end
147
+
148
+ def unrepresentable_attribute(group, model:, attribute:, path: nil)
149
+ add(
150
+ :unrepresentable_attribute,
151
+ 'Model attribute cannot be represented in this group',
152
+ group_id: group.id,
153
+ path: path || group.xpath,
154
+ details: { model:, attribute: }
155
+ )
156
+ end
157
+
158
+ def report
159
+ Report.new(profile:, issues: @issues)
160
+ end
161
+ end
162
+ end
163
+ end