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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -2
- data/NOTICE.md +2 -0
- data/README.md +62 -4
- data/lib/facturx/builders/association.rb +8 -0
- data/lib/facturx/builders/base.rb +129 -0
- data/lib/facturx/builders/schema.rb +112 -0
- data/lib/facturx/builders.rb +25 -0
- data/lib/facturx/coerce.rb +74 -0
- data/lib/facturx/conformance.rb +163 -0
- data/lib/facturx/diagnostic.rb +20 -0
- data/lib/facturx/document.rb +45 -0
- data/lib/facturx/error.rb +3 -0
- data/lib/facturx/format.rb +85 -0
- data/lib/facturx/generate.rb +21 -0
- data/lib/facturx/group.rb +5 -0
- data/lib/facturx/model/common.rb +8 -0
- data/lib/facturx/model/immutable.rb +75 -0
- data/lib/facturx/model/line.rb +14 -0
- data/lib/facturx/model/party.rb +10 -0
- data/lib/facturx/model/payment.rb +9 -0
- data/lib/facturx/model/reference.rb +7 -0
- data/lib/facturx/model/trade.rb +9 -0
- data/lib/facturx/model.rb +9 -0
- data/lib/facturx/pdf/extractor.rb +7 -4
- data/lib/facturx/profile_resolver.rb +40 -0
- data/lib/facturx/reader/semantic_mapper/delivery_mapping.rb +43 -0
- data/lib/facturx/reader/semantic_mapper/document_mapping.rb +109 -0
- data/lib/facturx/reader/semantic_mapper/helpers.rb +114 -0
- data/lib/facturx/reader/semantic_mapper/line_mapping.rb +120 -0
- data/lib/facturx/reader/semantic_mapper/line_references.rb +19 -0
- data/lib/facturx/reader/semantic_mapper/party_mapping.rb +90 -0
- data/lib/facturx/reader/semantic_mapper/payment_mapping.rb +108 -0
- data/lib/facturx/reader/semantic_mapper/trade_mapping.rb +83 -0
- data/lib/facturx/reader/semantic_mapper.rb +76 -0
- data/lib/facturx/reader/term_reader/coercion.rb +69 -0
- data/lib/facturx/reader/term_reader/unmapped.rb +37 -0
- data/lib/facturx/reader/term_reader.rb +132 -0
- data/lib/facturx/reader.rb +97 -0
- data/lib/facturx/reading.rb +23 -0
- data/lib/facturx/source_reader.rb +30 -0
- data/lib/facturx/term.rb +5 -0
- data/lib/facturx/terms/declaration.rb +20 -0
- data/lib/facturx/terms/declarations/adjustments.rb +230 -0
- data/lib/facturx/terms/declarations/delivery.rb +83 -0
- data/lib/facturx/terms/declarations/document.rb +223 -0
- data/lib/facturx/terms/declarations/groups.rb +356 -0
- data/lib/facturx/terms/declarations/lines.rb +166 -0
- data/lib/facturx/terms/declarations/parties.rb +609 -0
- data/lib/facturx/terms/declarations/payment.rb +106 -0
- data/lib/facturx/terms/declarations/products.rb +103 -0
- data/lib/facturx/terms/declarations/references.rb +72 -0
- data/lib/facturx/terms/declarations/taxes.rb +92 -0
- data/lib/facturx/terms/declarations/totals.rb +107 -0
- data/lib/facturx/terms/reference.rb +21 -0
- data/lib/facturx/terms.rb +109 -0
- data/lib/facturx/version.rb +1 -1
- data/lib/facturx/writer/context.rb +53 -0
- data/lib/facturx/writer/stage.rb +47 -0
- data/lib/facturx/writer/stage_support/contact_details.rb +57 -0
- data/lib/facturx/writer/stage_support/emission.rb +78 -0
- data/lib/facturx/writer/stage_support/identifiers.rb +129 -0
- data/lib/facturx/writer/stage_support/structure.rb +88 -0
- data/lib/facturx/writer/stages/document_context.rb +31 -0
- data/lib/facturx/writer/stages/exchanged_document.rb +32 -0
- data/lib/facturx/writer/stages/trade_agreement/parties.rb +105 -0
- data/lib/facturx/writer/stages/trade_agreement/references.rb +92 -0
- data/lib/facturx/writer/stages/trade_agreement.rb +45 -0
- data/lib/facturx/writer/stages/trade_delivery.rb +77 -0
- data/lib/facturx/writer/stages/trade_lines/agreement.rb +92 -0
- data/lib/facturx/writer/stages/trade_lines/product.rb +78 -0
- data/lib/facturx/writer/stages/trade_lines/quantity.rb +17 -0
- data/lib/facturx/writer/stages/trade_lines/settlement.rb +89 -0
- data/lib/facturx/writer/stages/trade_lines.rb +67 -0
- data/lib/facturx/writer/stages/trade_settlement/parties.rb +33 -0
- data/lib/facturx/writer/stages/trade_settlement/payment.rb +97 -0
- data/lib/facturx/writer/stages/trade_settlement/summary.rb +88 -0
- data/lib/facturx/writer/stages/trade_settlement/taxes.rb +121 -0
- data/lib/facturx/writer/stages/trade_settlement.rb +70 -0
- data/lib/facturx/writer.rb +68 -0
- data/lib/facturx/xml/namespaces.rb +19 -0
- data/lib/facturx/xml/profile_detector.rb +2 -4
- data/lib/facturx.rb +42 -0
- data/rbi/facturx.rbi +1090 -0
- metadata +79 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10cd4d86db6d643606dbd85da9ced31d20e884249505e8a608ebb864f9a2f2ef
|
|
4
|
+
data.tar.gz: 3e0665c807043cdca83550452ab2b1e3ca7d40b759105eea8856916a316576fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
###
|
|
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/
|
|
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)
|
|
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
|
-
|
|
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
|
|
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,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
|