facturx 0.2.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -3
  3. data/README.md +40 -8
  4. data/lib/facturx/builders/association.rb +8 -0
  5. data/lib/facturx/builders/base.rb +129 -0
  6. data/lib/facturx/builders/schema.rb +112 -0
  7. data/lib/facturx/builders.rb +25 -0
  8. data/lib/facturx/conformance.rb +163 -0
  9. data/lib/facturx/error.rb +3 -0
  10. data/lib/facturx/format.rb +85 -0
  11. data/lib/facturx/generate.rb +21 -0
  12. data/lib/facturx/profile_resolver.rb +40 -0
  13. data/lib/facturx/reader/semantic_mapper/line_mapping.rb +1 -1
  14. data/lib/facturx/reader/semantic_mapper/line_references.rb +19 -0
  15. data/lib/facturx/reader/semantic_mapper/payment_mapping.rb +48 -11
  16. data/lib/facturx/reader/semantic_mapper.rb +9 -0
  17. data/lib/facturx/reader/term_reader.rb +12 -4
  18. data/lib/facturx/reader.rb +3 -7
  19. data/lib/facturx/version.rb +1 -1
  20. data/lib/facturx/writer/context.rb +53 -0
  21. data/lib/facturx/writer/stage.rb +47 -0
  22. data/lib/facturx/writer/stage_support/contact_details.rb +57 -0
  23. data/lib/facturx/writer/stage_support/emission.rb +78 -0
  24. data/lib/facturx/writer/stage_support/identifiers.rb +129 -0
  25. data/lib/facturx/writer/stage_support/structure.rb +88 -0
  26. data/lib/facturx/writer/stages/document_context.rb +31 -0
  27. data/lib/facturx/writer/stages/exchanged_document.rb +32 -0
  28. data/lib/facturx/writer/stages/trade_agreement/parties.rb +105 -0
  29. data/lib/facturx/writer/stages/trade_agreement/references.rb +92 -0
  30. data/lib/facturx/writer/stages/trade_agreement.rb +45 -0
  31. data/lib/facturx/writer/stages/trade_delivery.rb +77 -0
  32. data/lib/facturx/writer/stages/trade_lines/agreement.rb +92 -0
  33. data/lib/facturx/writer/stages/trade_lines/product.rb +78 -0
  34. data/lib/facturx/writer/stages/trade_lines/quantity.rb +17 -0
  35. data/lib/facturx/writer/stages/trade_lines/settlement.rb +89 -0
  36. data/lib/facturx/writer/stages/trade_lines.rb +67 -0
  37. data/lib/facturx/writer/stages/trade_settlement/parties.rb +33 -0
  38. data/lib/facturx/writer/stages/trade_settlement/payment.rb +97 -0
  39. data/lib/facturx/writer/stages/trade_settlement/summary.rb +88 -0
  40. data/lib/facturx/writer/stages/trade_settlement/taxes.rb +121 -0
  41. data/lib/facturx/writer/stages/trade_settlement.rb +70 -0
  42. data/lib/facturx/writer.rb +68 -0
  43. data/lib/facturx/xml/namespaces.rb +19 -0
  44. data/lib/facturx/xml/profile_detector.rb +2 -4
  45. data/lib/facturx.rb +28 -0
  46. data/rbi/facturx.rbi +1090 -0
  47. metadata +38 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6318d858bdc82dd508e53e66c6f41ea56e455ce55df676b9e5268fa881f7a98
4
- data.tar.gz: 8b182edb44be499ec57bb1b0f98ecf892290584c0f959b73e7c787be460188d1
3
+ metadata.gz: 10cd4d86db6d643606dbd85da9ced31d20e884249505e8a608ebb864f9a2f2ef
4
+ data.tar.gz: 3e0665c807043cdca83550452ab2b1e3ca7d40b759105eea8856916a316576fd
5
5
  SHA512:
6
- metadata.gz: b529dd611b8f5c71c6aa9b9d9458ffeea4352fee2e62030e74f543a464d008c4b97bd186313b13d7b1966abf4f1614b13b9797729d7ece243aa71045e032e03b
7
- data.tar.gz: 4a25ca0ef9d9f4df72150925753a250989316a2a31e171b0b14de85e342950408ebc43d5d4d3150a91863fe07b6a805271bbda7fa3a2f9c2502c68d18111918d
6
+ metadata.gz: 0cf6cb2b75adb6a668a5746420ee82177aada0c87d9d40bd625387a59d1409b63ef53916df3480aa7adaf539b59c99fb635667134e3cefcbd66590f1a19aeecd
7
+ data.tar.gz: 3e7715e0181e11b9b0589ab85da8194e692647fcb4be0d42d177968584d0cc1f1e2114a782e8d533c7c5a2fbac0943bc2bd4964e59633773c9dd864e5a42cee4
data/CHANGELOG.md CHANGED
@@ -4,9 +4,26 @@ 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
+
7
24
  ## [0.2.0] - 2026-09-12
8
25
 
9
- ### Added
26
+ ### Add
10
27
 
11
28
  - Read XML or PDF bytes into an immutable, typed EN16931 document model with structured diagnostics.
12
29
  - Map the complete EN16931 semantic term set and the MINIMUM, BASIC WL, and BASIC subsets.
@@ -14,12 +31,13 @@ All notable changes to this project will be documented in this file.
14
31
 
15
32
  ## [0.1.0] - 2026-09-11
16
33
 
17
- ### Added
34
+ ### Add
18
35
 
19
36
  - Validate Factur-X 1.09.2 XML against its profile XSD.
20
37
  - Compose PDF/A-3b Factur-X documents with Ghostscript.
21
38
  - Extract embedded Factur-X XML from PDF documents.
22
39
 
23
- [Unreleased]: https://github.com/AdVitam/facturx/compare/v0.2.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
24
42
  [0.2.0]: https://github.com/AdVitam/facturx/compare/v0.1.0...v0.2.0
25
43
  [0.1.0]: https://github.com/AdVitam/facturx/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # facturx
2
2
 
3
- `facturx` reads and 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,12 +26,12 @@ 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
30
 
31
31
  reading = Facturx.read(xml)
32
- reading.document.invoice_number # => "F-2023-004"
33
- reading.document.totals.grand_total # => BigDecimal("120.00")
34
- reading.diagnostics # => [] for a complete EN16931 document
32
+ reading.document.invoice_number
33
+ reading.document.totals.grand_total
34
+ reading.diagnostics
35
35
 
36
36
  facturx_pdf = Facturx.attach(pdf: pdf, xml: xml)
37
37
  File.binwrite('invoice-facturx.pdf', facturx_pdf)
@@ -40,6 +40,38 @@ embedded_xml = Facturx.extract_xml(pdf: facturx_pdf)
40
40
  pdf_reading = Facturx.read(facturx_pdf)
41
41
  ```
42
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)
69
+ ```
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
+
43
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.
44
76
 
45
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.
@@ -58,7 +90,7 @@ Facturx.read(xml, on_unknown_profile: :raise)
58
90
 
59
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.
60
92
 
61
- Reading is not a fidelity round-trip. To reissue an incoming invoice, retain and reuse `Reading#source`; a future writer built from `Reading#document` cannot reproduce fields outside the semantic model.
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.
62
94
 
63
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.
64
96
 
@@ -76,9 +108,9 @@ The gem invokes Ghostscript as an external process. It does not distribute Ghost
76
108
 
77
109
  ## Validation scope
78
110
 
79
- 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.
80
112
 
81
- 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.
82
114
 
83
115
  ## Development
84
116
 
@@ -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,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
data/lib/facturx/error.rb CHANGED
@@ -13,6 +13,9 @@ module Facturx
13
13
  class InvalidXmlError < Error; end
14
14
  class UnknownProfileError < InvalidXmlError; end
15
15
  class XsdValidationError < InvalidXmlError; end
16
+ class UnsupportedProfileError < Error; end
17
+ class FormattingError < Error; end
18
+ class ConformanceError < Error; end
16
19
  class InvalidPdfError < Error; end
17
20
  class ProtectedPdfError < InvalidPdfError; end
18
21
  class ComposerUnavailableError < Error; end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+ require 'date'
5
+ require_relative 'error'
6
+
7
+ module Facturx
8
+ module Format
9
+ FORMATTERS = {
10
+ string: :string,
11
+ date_102: :formatted_date,
12
+ decimal: :decimal,
13
+ boolean: :boolean,
14
+ binary: :binary
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ def call(value, term:)
20
+ formatter = FORMATTERS[term.type]
21
+ raise ArgumentError, "Unsupported format type: #{term.type.inspect}" unless formatter
22
+
23
+ begin
24
+ public_send(formatter, value, term.scale)
25
+ rescue ArgumentError, TypeError => e
26
+ raise FormattingError.new(
27
+ 'Value cannot be formatted',
28
+ value:, type: term.type, scale: term.scale, term_id: term.id, group_id: term.group_id, path: term.xpath
29
+ ), cause: e
30
+ end
31
+ end
32
+
33
+ def string(value, _scale = nil)
34
+ raise TypeError, 'expected a String' unless value.is_a?(String)
35
+ raise ArgumentError, 'String must contain a non-whitespace character' if value.match?(/\A[[:space:]]*\z/)
36
+
37
+ value.dup.freeze
38
+ end
39
+
40
+ def formatted_date(value, _scale = nil)
41
+ raise TypeError, 'expected a Date' unless value.instance_of?(Date)
42
+
43
+ value.strftime('%Y%m%d').freeze
44
+ end
45
+
46
+ def decimal(value, scale = nil)
47
+ raise TypeError, 'expected a BigDecimal or Integer' unless value.is_a?(BigDecimal) || value.is_a?(Integer)
48
+ raise ArgumentError, 'decimal must be finite' if value.is_a?(BigDecimal) && !value.finite?
49
+
50
+ case scale
51
+ when nil then fixed_decimal(value)
52
+ when 2 then fixed_scale(value, 2)
53
+ else raise ArgumentError, "Unsupported decimal scale: #{scale.inspect}"
54
+ end.freeze
55
+ end
56
+
57
+ def boolean(value, _scale = nil)
58
+ return 'true' if value.equal?(true)
59
+ return 'false' if value.equal?(false)
60
+
61
+ raise TypeError, 'expected true or false'
62
+ end
63
+
64
+ def binary(value, _scale = nil)
65
+ raise TypeError, 'expected a binary String' unless value.is_a?(String) && value.encoding == Encoding::BINARY
66
+
67
+ [value].pack('m0').freeze
68
+ end
69
+
70
+ def fixed_decimal(value)
71
+ value.is_a?(Integer) ? value.to_s : value.to_s('F')
72
+ end
73
+ private_class_method :fixed_decimal
74
+
75
+ def fixed_scale(value, scale)
76
+ decimal_value = value.is_a?(Integer) ? BigDecimal(value) : value
77
+ rounded = decimal_value.round(scale)
78
+ raise ArgumentError, "decimal has more than #{scale} non-zero fractional digits" unless rounded == decimal_value
79
+
80
+ integer, fraction = rounded.to_s('F').split('.', 2)
81
+ "#{integer}.#{fraction.to_s.ljust(scale, '0')}"
82
+ end
83
+ private_class_method :fixed_scale
84
+ end
85
+ end