facturx 0.1.0 → 0.2.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 +10 -1
- data/NOTICE.md +2 -0
- data/README.md +27 -1
- data/lib/facturx/coerce.rb +74 -0
- data/lib/facturx/diagnostic.rb +20 -0
- data/lib/facturx/document.rb +45 -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/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/party_mapping.rb +90 -0
- data/lib/facturx/reader/semantic_mapper/payment_mapping.rb +71 -0
- data/lib/facturx/reader/semantic_mapper/trade_mapping.rb +83 -0
- data/lib/facturx/reader/semantic_mapper.rb +67 -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 +124 -0
- data/lib/facturx/reader.rb +101 -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.rb +14 -0
- metadata +45 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e6318d858bdc82dd508e53e66c6f41ea56e455ce55df676b9e5268fa881f7a98
|
|
4
|
+
data.tar.gz: 8b182edb44be499ec57bb1b0f98ecf892290584c0f959b73e7c787be460188d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b529dd611b8f5c71c6aa9b9d9458ffeea4352fee2e62030e74f543a464d008c4b97bd186313b13d7b1966abf4f1614b13b9797729d7ece243aa71045e032e03b
|
|
7
|
+
data.tar.gz: 4a25ca0ef9d9f4df72150925753a250989316a2a31e171b0b14de85e342950408ebc43d5d4d3150a91863fe07b6a805271bbda7fa3a2f9c2502c68d18111918d
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.0] - 2026-09-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Read XML or PDF bytes into an immutable, typed EN16931 document model with structured diagnostics.
|
|
12
|
+
- Map the complete EN16931 semantic term set and the MINIMUM, BASIC WL, and BASIC subsets.
|
|
13
|
+
- Verify semantic mapping provenance and official examples with the maintainer reference task.
|
|
14
|
+
|
|
7
15
|
## [0.1.0] - 2026-09-11
|
|
8
16
|
|
|
9
17
|
### Added
|
|
@@ -12,5 +20,6 @@ All notable changes to this project will be documented in this file.
|
|
|
12
20
|
- Compose PDF/A-3b Factur-X documents with Ghostscript.
|
|
13
21
|
- Extract embedded Factur-X XML from PDF documents.
|
|
14
22
|
|
|
15
|
-
[Unreleased]: https://github.com/AdVitam/facturx/compare/v0.
|
|
23
|
+
[Unreleased]: https://github.com/AdVitam/facturx/compare/v0.2.0...HEAD
|
|
24
|
+
[0.2.0]: https://github.com/AdVitam/facturx/compare/v0.1.0...v0.2.0
|
|
16
25
|
[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` 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
|
|
|
@@ -28,10 +28,16 @@ pdf = File.binread('invoice.pdf')
|
|
|
28
28
|
|
|
29
29
|
Facturx.verify_xml(xml: xml) # => true
|
|
30
30
|
|
|
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
|
|
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)
|
|
35
41
|
```
|
|
36
42
|
|
|
37
43
|
`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.
|
|
@@ -40,6 +46,20 @@ embedded_xml = Facturx.extract_xml(pdf: facturx_pdf)
|
|
|
40
46
|
|
|
41
47
|
`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
48
|
|
|
49
|
+
`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`.
|
|
50
|
+
|
|
51
|
+
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.
|
|
52
|
+
|
|
53
|
+
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:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
Facturx.read(xml, on_unknown_profile: :raise)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
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
|
+
|
|
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.
|
|
62
|
+
|
|
43
63
|
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
64
|
|
|
45
65
|
## Ghostscript discovery
|
|
@@ -70,6 +90,12 @@ bundle exec rspec
|
|
|
70
90
|
bundle exec rake build
|
|
71
91
|
```
|
|
72
92
|
|
|
93
|
+
Maintainers can verify the registry hashes, D22B element names, semantic diagnostics, official XML examples, and paired PDF attachments against an extracted upstream package:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
FACTURX_REFERENCE_ROOT=/path/to/ZUGFeRD_2.5.2_EN bundle exec rake reference:verify
|
|
97
|
+
```
|
|
98
|
+
|
|
73
99
|
## License
|
|
74
100
|
|
|
75
101
|
The gem code is available under the MIT License. Bundled standard artefacts retain their respective upstream notices; see `NOTICE.md`.
|
|
@@ -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,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'model/immutable'
|
|
4
|
+
|
|
5
|
+
module Facturx
|
|
6
|
+
Diagnostic = Data.define(:code, :term_id, :path, :message, :details) do
|
|
7
|
+
include Model::ValidatedWith
|
|
8
|
+
|
|
9
|
+
def initialize(code:, message:, term_id: nil, path: nil, details: {})
|
|
10
|
+
raise TypeError, 'details must be a Hash' unless details.is_a?(Hash)
|
|
11
|
+
|
|
12
|
+
bounded_details = details.to_h do |key, value|
|
|
13
|
+
[key, value.is_a?(String) ? value.byteslice(0, 200).scrub('') : value]
|
|
14
|
+
end
|
|
15
|
+
super(code: Model.copy_and_freeze(code), term_id: Model.copy_and_freeze(term_id),
|
|
16
|
+
path: Model.copy_and_freeze(path), message: Model.copy_and_freeze(message),
|
|
17
|
+
details: Model.copy_and_freeze(bounded_details))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'model'
|
|
4
|
+
|
|
5
|
+
module Facturx
|
|
6
|
+
Document = Model.define(
|
|
7
|
+
:guideline_urn,
|
|
8
|
+
:business_process,
|
|
9
|
+
:invoice_number,
|
|
10
|
+
:issue_date,
|
|
11
|
+
:type_code,
|
|
12
|
+
:currency,
|
|
13
|
+
:tax_currency,
|
|
14
|
+
:vat_point_date,
|
|
15
|
+
:vat_point_date_code,
|
|
16
|
+
:payment_due_date,
|
|
17
|
+
:buyer_reference,
|
|
18
|
+
:project_reference,
|
|
19
|
+
:contract_reference,
|
|
20
|
+
:purchase_order_reference,
|
|
21
|
+
:sales_order_reference,
|
|
22
|
+
:receiving_advice_reference,
|
|
23
|
+
:despatch_advice_reference,
|
|
24
|
+
:tender_or_lot_reference,
|
|
25
|
+
:invoiced_object_identifier,
|
|
26
|
+
:buyer_accounting_reference,
|
|
27
|
+
:payment_terms,
|
|
28
|
+
:notes,
|
|
29
|
+
:seller,
|
|
30
|
+
:buyer,
|
|
31
|
+
:payee,
|
|
32
|
+
:tax_representative,
|
|
33
|
+
:delivery,
|
|
34
|
+
:billing_period,
|
|
35
|
+
:payment,
|
|
36
|
+
:lines,
|
|
37
|
+
:tax_breakdowns,
|
|
38
|
+
:allowances,
|
|
39
|
+
:charges,
|
|
40
|
+
:totals,
|
|
41
|
+
:preceding_invoices,
|
|
42
|
+
:supporting_documents,
|
|
43
|
+
collections: %i[notes lines tax_breakdowns allowances charges preceding_invoices supporting_documents]
|
|
44
|
+
)
|
|
45
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
module Model
|
|
5
|
+
module Immutability
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def copy(value)
|
|
9
|
+
case value
|
|
10
|
+
when Float then raise TypeError, 'Float values are not supported; use BigDecimal for decimal values'
|
|
11
|
+
when String then value.dup.freeze
|
|
12
|
+
when Array then value.map { |item| copy(item) }.freeze
|
|
13
|
+
when Hash then value.to_h { |key, item| [copy(key), copy(item)] }.freeze
|
|
14
|
+
else copy_object(value)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def copy_object(value)
|
|
19
|
+
immutable = value.nil? || value == true || value == false || value.is_a?(Numeric) || value.is_a?(Symbol)
|
|
20
|
+
return value if immutable || value.frozen?
|
|
21
|
+
|
|
22
|
+
value.dup.freeze
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module ValidatedWith
|
|
27
|
+
def with(**attributes)
|
|
28
|
+
self.class.new(**to_h, **attributes)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
MISSING = Object.new.freeze
|
|
33
|
+
private_constant :MISSING
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def define(*members, collections: [])
|
|
38
|
+
collection_members = collections.map(&:to_sym).freeze
|
|
39
|
+
|
|
40
|
+
Data.define(*members) do
|
|
41
|
+
include ValidatedWith
|
|
42
|
+
|
|
43
|
+
define_method(:initialize) do |**attributes|
|
|
44
|
+
normalized = Model.normalize_attributes(self.class.members, collection_members, attributes)
|
|
45
|
+
super(**normalized)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def normalize_attributes(members, collections, attributes)
|
|
51
|
+
validate_attributes!(members, attributes)
|
|
52
|
+
members.to_h do |member|
|
|
53
|
+
value = attributes.fetch(member, MISSING)
|
|
54
|
+
[member, normalize_value(member, value, collections.include?(member))]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_attributes!(members, attributes)
|
|
59
|
+
unknown = attributes.keys - members
|
|
60
|
+
raise ArgumentError, "Unknown attributes: #{unknown.map(&:inspect).join(', ')}" unless unknown.empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalize_value(member, value, collection)
|
|
64
|
+
value = nil if value.equal?(MISSING)
|
|
65
|
+
value = [] if collection && value.nil?
|
|
66
|
+
raise TypeError, "#{member} must be an Array" if collection && !value.is_a?(Array)
|
|
67
|
+
|
|
68
|
+
copy_and_freeze(value)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def copy_and_freeze(value)
|
|
72
|
+
Immutability.copy(value)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
ProductAttribute = Model.define(:name, :value)
|
|
5
|
+
ProductClassification = Model.define(:code, :list_id, :list_version_id)
|
|
6
|
+
Product = Model.define(:name, :description, :seller_identifier, :buyer_identifier, :global_identifier,
|
|
7
|
+
:origin_country_code, :attributes, :classifications,
|
|
8
|
+
collections: %i[attributes classifications])
|
|
9
|
+
Price = Model.define(:amount, :basis_quantity, :discount)
|
|
10
|
+
Line = Model.define(:id, :note, :product, :quantity, :gross_price, :net_price, :tax, :period, :net_amount,
|
|
11
|
+
:allowances, :charges, :buyer_order_reference, :buyer_accounting_reference,
|
|
12
|
+
:invoiced_object_identifier,
|
|
13
|
+
collections: %i[allowances charges])
|
|
14
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
Contact = Model.define(:name, :telephone, :email)
|
|
5
|
+
Address = Model.define(:postcode, :line_one, :line_two, :line_three, :city, :country_code,
|
|
6
|
+
:country_subdivision)
|
|
7
|
+
Party = Model.define(:identifiers, :name, :description, :legal_registration, :trading_name, :address,
|
|
8
|
+
:vat_identifier, :tax_identifier, :electronic_address, :contact,
|
|
9
|
+
collections: [:identifiers])
|
|
10
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
CreditTransfer = Model.define(:account_identifier, :account_name, :provider_identifier)
|
|
5
|
+
PaymentCard = Model.define(:primary_account_number, :holder_name)
|
|
6
|
+
DirectDebit = Model.define(:mandate_identifier, :creditor_identifier, :debtor_account_identifier)
|
|
7
|
+
PaymentInstructions = Model.define(:means_code, :means_text, :remittance_information, :credit_transfers,
|
|
8
|
+
:payment_card, :direct_debit, collections: [:credit_transfers])
|
|
9
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
DocumentReference = Model.define(:id, :line_id, :name, :issue_date)
|
|
5
|
+
Delivery = Model.define(:location_identifier, :party, :date)
|
|
6
|
+
SupportingDocument = Model.define(:reference, :description, :external_location, :content, :mime_code, :filename)
|
|
7
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
TaxBreakdown = Model.define(:type_code, :category_code, :rate, :basis_amount, :tax_amount,
|
|
5
|
+
:exemption_reason, :exemption_reason_code, :due_date_type_code)
|
|
6
|
+
AllowanceCharge = Model.define(:indicator, :amount, :base_amount, :percentage, :reason, :reason_code, :tax)
|
|
7
|
+
Totals = Model.define(:line_total, :charge_total, :allowance_total, :tax_basis_total, :tax_total,
|
|
8
|
+
:tax_total_in_tax_currency, :grand_total, :prepaid, :rounding, :due_payable)
|
|
9
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'model/immutable'
|
|
4
|
+
require_relative 'model/common'
|
|
5
|
+
require_relative 'model/party'
|
|
6
|
+
require_relative 'model/reference'
|
|
7
|
+
require_relative 'model/payment'
|
|
8
|
+
require_relative 'model/trade'
|
|
9
|
+
require_relative 'model/line'
|
|
@@ -7,6 +7,7 @@ require_relative '../embedding'
|
|
|
7
7
|
module Facturx
|
|
8
8
|
module Pdf
|
|
9
9
|
class Extractor
|
|
10
|
+
ACCEPTED_RELATIONSHIPS = %i[Alternative Data].freeze
|
|
10
11
|
Result = Data.define(:xml, :filename, :relationship, :metadata, :page_count)
|
|
11
12
|
Candidate = Data.define(:file_specification, :names)
|
|
12
13
|
|
|
@@ -31,7 +32,7 @@ module Facturx
|
|
|
31
32
|
|
|
32
33
|
Result.new(xml: embedded_xml(document, file_specification),
|
|
33
34
|
filename: FACTURX_EMBEDDING.filename,
|
|
34
|
-
relationship:
|
|
35
|
+
relationship: file_specification[:AFRelationship],
|
|
35
36
|
metadata: document.metadata,
|
|
36
37
|
page_count: document.page_count)
|
|
37
38
|
end
|
|
@@ -50,7 +51,9 @@ module Facturx
|
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
def matching_candidates(candidates)
|
|
53
|
-
candidates
|
|
54
|
+
candidates
|
|
55
|
+
.select { |candidate| candidate.names.include?(FACTURX_EMBEDDING.filename) }
|
|
56
|
+
.uniq { |candidate| candidate.file_specification.object_id }
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
def associated_file_candidates(document)
|
|
@@ -93,9 +96,9 @@ module Facturx
|
|
|
93
96
|
end
|
|
94
97
|
|
|
95
98
|
def validate_relationship!(file_specification)
|
|
96
|
-
return if file_specification[:AFRelationship]
|
|
99
|
+
return if ACCEPTED_RELATIONSHIPS.include?(file_specification[:AFRelationship])
|
|
97
100
|
|
|
98
|
-
raise ExtractionError.new('Factur-X attachment
|
|
101
|
+
raise ExtractionError.new('Factur-X attachment uses an unsupported relationship',
|
|
99
102
|
reason: :invalid_relationship,
|
|
100
103
|
relationship: file_specification[:AFRelationship])
|
|
101
104
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
class Reader
|
|
5
|
+
class SemanticMapper
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def delivery
|
|
9
|
+
node = first_group('BG-13-00')
|
|
10
|
+
return unless node
|
|
11
|
+
|
|
12
|
+
base = group_xpath('BG-13-00')
|
|
13
|
+
party_node = first_group('BG-13', context: node, base_xpath: base)
|
|
14
|
+
party = delivery_party(party_node) if party_node
|
|
15
|
+
Delivery.new(
|
|
16
|
+
location_identifier: party_node && delivery_identifier(party_node),
|
|
17
|
+
party:,
|
|
18
|
+
date: value('BT-72', context: node, base_xpath: base)
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def delivery_party(node)
|
|
23
|
+
base = group_xpath('BG-13')
|
|
24
|
+
Party.new(
|
|
25
|
+
name: value('BT-70', context: node, base_xpath: base),
|
|
26
|
+
address: address(node, base, 'BG-15')
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def delivery_identifier(node)
|
|
31
|
+
base = group_xpath('BG-13')
|
|
32
|
+
global_id = node.at_xpath('./ram:GlobalID', NAMESPACES)
|
|
33
|
+
@terms.mark(global_id) if global_id
|
|
34
|
+
local_id = value('BT-71', context: node, base_xpath: base)
|
|
35
|
+
return Identifier.new(value: local_id) if local_id
|
|
36
|
+
|
|
37
|
+
return unless global_id
|
|
38
|
+
|
|
39
|
+
Identifier.new(value: global_id.text, scheme_id: global_id['schemeID'])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
class Reader
|
|
5
|
+
class SemanticMapper
|
|
6
|
+
DOCUMENT_REFERENCE_ATTRIBUTES = %i[
|
|
7
|
+
sales_order_reference purchase_order_reference contract_reference receiving_advice_reference
|
|
8
|
+
despatch_advice_reference
|
|
9
|
+
].freeze
|
|
10
|
+
DOCUMENT_COMPOSITE_ATTRIBUTES = [
|
|
11
|
+
*DOCUMENT_REFERENCE_ATTRIBUTES, :tender_or_lot_reference, :invoiced_object_identifier, :project_reference
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def document_attributes(tax_breakdowns)
|
|
17
|
+
scalar_document_attributes(tax_breakdowns).merge(
|
|
18
|
+
reference_document_attributes,
|
|
19
|
+
tender_or_lot_reference: additional_reference(:tender_or_lot_reference, type_code: '50'),
|
|
20
|
+
invoiced_object_identifier: invoiced_object_identifier,
|
|
21
|
+
project_reference: project_reference
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def scalar_document_attributes(tax_breakdowns)
|
|
26
|
+
attributes = [nil, 'BG-2', 'BG-19'].each_with_object({}) do |group_id, result|
|
|
27
|
+
result.merge!(scalar_attributes(:document, group_id, except: DOCUMENT_COMPOSITE_ATTRIBUTES))
|
|
28
|
+
end
|
|
29
|
+
attributes.merge(vat_point_date: tax_breakdowns.vat_point_date,
|
|
30
|
+
vat_point_date_code: tax_breakdowns.items.find(&:due_date_type_code)&.due_date_type_code)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reference_document_attributes
|
|
34
|
+
DOCUMENT_REFERENCE_ATTRIBUTES.to_h do |attribute|
|
|
35
|
+
[attribute, reference(term_for_attribute(:document, attribute)&.id)]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def notes
|
|
40
|
+
group_nodes('BG-1').map do |node|
|
|
41
|
+
Note.new(**scalar_attributes(:note, 'BG-1', context: node, base_xpath: group_xpath('BG-1')))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def preceding_invoices
|
|
46
|
+
group_nodes('BG-3').map do |node|
|
|
47
|
+
base = group_xpath('BG-3')
|
|
48
|
+
DocumentReference.new(**scalar_attributes(:document_reference, 'BG-3', context: node, base_xpath: base))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def supporting_documents
|
|
53
|
+
group_nodes('BG-24') { |node| supporting_document?(node) }.map do |node|
|
|
54
|
+
base = group_xpath('BG-24')
|
|
55
|
+
SupportingDocument.new(**supporting_document_attributes(node, base))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def supporting_document_attributes(node, base)
|
|
60
|
+
binary = %i[content mime_code filename]
|
|
61
|
+
attributes = scalar_attributes(:supporting_document, 'BG-24', context: node, base_xpath: base, except: binary)
|
|
62
|
+
attributes.merge!(embedded_document_attributes(node, base)) if node.at_xpath('./ram:AttachmentBinaryObject',
|
|
63
|
+
NAMESPACES)
|
|
64
|
+
attributes
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def embedded_document_attributes(node, base)
|
|
68
|
+
scalar_attributes(:supporting_document, 'BG-24', context: node, base_xpath: base,
|
|
69
|
+
only: %i[content mime_code filename])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def supporting_document?(node)
|
|
73
|
+
technical_value('./ram:TypeCode', node) == '916'
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def additional_reference(attribute, type_code:)
|
|
77
|
+
node = @document.at_xpath("#{group_xpath('BG-24')}[ram:TypeCode='#{type_code}']", NAMESPACES)
|
|
78
|
+
return unless node
|
|
79
|
+
|
|
80
|
+
technical_value('./ram:TypeCode', node)
|
|
81
|
+
term = term_for_attribute(:document, attribute)
|
|
82
|
+
reference(term&.id, context: node, base_xpath: group_xpath('BG-24'))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def invoiced_object_identifier
|
|
86
|
+
node = @document.at_xpath("#{group_xpath('BG-24')}[ram:TypeCode='130']", NAMESPACES)
|
|
87
|
+
return unless node
|
|
88
|
+
|
|
89
|
+
technical_value('./ram:TypeCode', node)
|
|
90
|
+
value_term = term_for_attribute(:document, :invoiced_object_identifier, xpath_suffix: '/ram:IssuerAssignedID')
|
|
91
|
+
scheme_term = term_for_attribute(:document, :invoiced_object_identifier, xpath_suffix: '/ram:ReferenceTypeCode')
|
|
92
|
+
identifier(value_term&.id, scheme_term&.id, context: node, base_xpath: group_xpath('BG-24'))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def project_reference
|
|
96
|
+
node = @document.at_xpath(
|
|
97
|
+
'/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction/' \
|
|
98
|
+
'ram:ApplicableHeaderTradeAgreement/ram:SpecifiedProcuringProject',
|
|
99
|
+
NAMESPACES
|
|
100
|
+
)
|
|
101
|
+
return unless node
|
|
102
|
+
|
|
103
|
+
id = value(term_for_attribute(:document, :project_reference)&.id)
|
|
104
|
+
name = technical_value('./ram:Name', node)
|
|
105
|
+
DocumentReference.new(id:, name:)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|