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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -1
  3. data/NOTICE.md +2 -0
  4. data/README.md +27 -1
  5. data/lib/facturx/coerce.rb +74 -0
  6. data/lib/facturx/diagnostic.rb +20 -0
  7. data/lib/facturx/document.rb +45 -0
  8. data/lib/facturx/group.rb +5 -0
  9. data/lib/facturx/model/common.rb +8 -0
  10. data/lib/facturx/model/immutable.rb +75 -0
  11. data/lib/facturx/model/line.rb +14 -0
  12. data/lib/facturx/model/party.rb +10 -0
  13. data/lib/facturx/model/payment.rb +9 -0
  14. data/lib/facturx/model/reference.rb +7 -0
  15. data/lib/facturx/model/trade.rb +9 -0
  16. data/lib/facturx/model.rb +9 -0
  17. data/lib/facturx/pdf/extractor.rb +7 -4
  18. data/lib/facturx/reader/semantic_mapper/delivery_mapping.rb +43 -0
  19. data/lib/facturx/reader/semantic_mapper/document_mapping.rb +109 -0
  20. data/lib/facturx/reader/semantic_mapper/helpers.rb +114 -0
  21. data/lib/facturx/reader/semantic_mapper/line_mapping.rb +120 -0
  22. data/lib/facturx/reader/semantic_mapper/party_mapping.rb +90 -0
  23. data/lib/facturx/reader/semantic_mapper/payment_mapping.rb +71 -0
  24. data/lib/facturx/reader/semantic_mapper/trade_mapping.rb +83 -0
  25. data/lib/facturx/reader/semantic_mapper.rb +67 -0
  26. data/lib/facturx/reader/term_reader/coercion.rb +69 -0
  27. data/lib/facturx/reader/term_reader/unmapped.rb +37 -0
  28. data/lib/facturx/reader/term_reader.rb +124 -0
  29. data/lib/facturx/reader.rb +101 -0
  30. data/lib/facturx/reading.rb +23 -0
  31. data/lib/facturx/source_reader.rb +30 -0
  32. data/lib/facturx/term.rb +5 -0
  33. data/lib/facturx/terms/declaration.rb +20 -0
  34. data/lib/facturx/terms/declarations/adjustments.rb +230 -0
  35. data/lib/facturx/terms/declarations/delivery.rb +83 -0
  36. data/lib/facturx/terms/declarations/document.rb +223 -0
  37. data/lib/facturx/terms/declarations/groups.rb +356 -0
  38. data/lib/facturx/terms/declarations/lines.rb +166 -0
  39. data/lib/facturx/terms/declarations/parties.rb +609 -0
  40. data/lib/facturx/terms/declarations/payment.rb +106 -0
  41. data/lib/facturx/terms/declarations/products.rb +103 -0
  42. data/lib/facturx/terms/declarations/references.rb +72 -0
  43. data/lib/facturx/terms/declarations/taxes.rb +92 -0
  44. data/lib/facturx/terms/declarations/totals.rb +107 -0
  45. data/lib/facturx/terms/reference.rb +21 -0
  46. data/lib/facturx/terms.rb +109 -0
  47. data/lib/facturx/version.rb +1 -1
  48. data/lib/facturx.rb +14 -0
  49. metadata +45 -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: e6318d858bdc82dd508e53e66c6f41ea56e455ce55df676b9e5268fa881f7a98
4
+ data.tar.gz: 8b182edb44be499ec57bb1b0f98ecf892290584c0f959b73e7c787be460188d1
5
5
  SHA512:
6
- metadata.gz: aa5602b1058a4e5cd06821ea7995d761983b9ddf6cb736e3bcc46e8473230baaf6f215b8f78034f633f19223072b24ce6e474466f5a534801e89516e6d949854
7
- data.tar.gz: 3f9504f7f0c04245bd50948d38a1608f1accc18c5bda8dc953229ef60ac4e7cd1071aa15bef81172007e41d6c4eae76cbeaa4999d67d2fb80e52fe9dd00db4da
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.1.0...HEAD
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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ Group = Data.define(:id, :model, :attribute, :parent_id, :xpath, :cardinalities)
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ Note = Model.define(:content, :subject_code)
5
+ Identifier = Model.define(:value, :scheme_id)
6
+ Period = Model.define(:start_date, :end_date)
7
+ Quantity = Model.define(:value, :unit_code)
8
+ 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: FACTURX_EMBEDDING.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.select { |candidate| candidate.names.include?(FACTURX_EMBEDDING.filename) }
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] == FACTURX_EMBEDDING.relationship
99
+ return if ACCEPTED_RELATIONSHIPS.include?(file_specification[:AFRelationship])
97
100
 
98
- raise ExtractionError.new('Factur-X attachment must use the Alternative relationship',
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