facturx 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -2
  3. data/NOTICE.md +2 -0
  4. data/README.md +62 -4
  5. data/lib/facturx/builders/association.rb +8 -0
  6. data/lib/facturx/builders/base.rb +129 -0
  7. data/lib/facturx/builders/schema.rb +112 -0
  8. data/lib/facturx/builders.rb +25 -0
  9. data/lib/facturx/coerce.rb +74 -0
  10. data/lib/facturx/conformance.rb +163 -0
  11. data/lib/facturx/diagnostic.rb +20 -0
  12. data/lib/facturx/document.rb +45 -0
  13. data/lib/facturx/error.rb +3 -0
  14. data/lib/facturx/format.rb +85 -0
  15. data/lib/facturx/generate.rb +21 -0
  16. data/lib/facturx/group.rb +5 -0
  17. data/lib/facturx/model/common.rb +8 -0
  18. data/lib/facturx/model/immutable.rb +75 -0
  19. data/lib/facturx/model/line.rb +14 -0
  20. data/lib/facturx/model/party.rb +10 -0
  21. data/lib/facturx/model/payment.rb +9 -0
  22. data/lib/facturx/model/reference.rb +7 -0
  23. data/lib/facturx/model/trade.rb +9 -0
  24. data/lib/facturx/model.rb +9 -0
  25. data/lib/facturx/pdf/extractor.rb +7 -4
  26. data/lib/facturx/profile_resolver.rb +40 -0
  27. data/lib/facturx/reader/semantic_mapper/delivery_mapping.rb +43 -0
  28. data/lib/facturx/reader/semantic_mapper/document_mapping.rb +109 -0
  29. data/lib/facturx/reader/semantic_mapper/helpers.rb +114 -0
  30. data/lib/facturx/reader/semantic_mapper/line_mapping.rb +120 -0
  31. data/lib/facturx/reader/semantic_mapper/line_references.rb +19 -0
  32. data/lib/facturx/reader/semantic_mapper/party_mapping.rb +90 -0
  33. data/lib/facturx/reader/semantic_mapper/payment_mapping.rb +108 -0
  34. data/lib/facturx/reader/semantic_mapper/trade_mapping.rb +83 -0
  35. data/lib/facturx/reader/semantic_mapper.rb +76 -0
  36. data/lib/facturx/reader/term_reader/coercion.rb +69 -0
  37. data/lib/facturx/reader/term_reader/unmapped.rb +37 -0
  38. data/lib/facturx/reader/term_reader.rb +132 -0
  39. data/lib/facturx/reader.rb +97 -0
  40. data/lib/facturx/reading.rb +23 -0
  41. data/lib/facturx/source_reader.rb +30 -0
  42. data/lib/facturx/term.rb +5 -0
  43. data/lib/facturx/terms/declaration.rb +20 -0
  44. data/lib/facturx/terms/declarations/adjustments.rb +230 -0
  45. data/lib/facturx/terms/declarations/delivery.rb +83 -0
  46. data/lib/facturx/terms/declarations/document.rb +223 -0
  47. data/lib/facturx/terms/declarations/groups.rb +356 -0
  48. data/lib/facturx/terms/declarations/lines.rb +166 -0
  49. data/lib/facturx/terms/declarations/parties.rb +609 -0
  50. data/lib/facturx/terms/declarations/payment.rb +106 -0
  51. data/lib/facturx/terms/declarations/products.rb +103 -0
  52. data/lib/facturx/terms/declarations/references.rb +72 -0
  53. data/lib/facturx/terms/declarations/taxes.rb +92 -0
  54. data/lib/facturx/terms/declarations/totals.rb +107 -0
  55. data/lib/facturx/terms/reference.rb +21 -0
  56. data/lib/facturx/terms.rb +109 -0
  57. data/lib/facturx/version.rb +1 -1
  58. data/lib/facturx/writer/context.rb +53 -0
  59. data/lib/facturx/writer/stage.rb +47 -0
  60. data/lib/facturx/writer/stage_support/contact_details.rb +57 -0
  61. data/lib/facturx/writer/stage_support/emission.rb +78 -0
  62. data/lib/facturx/writer/stage_support/identifiers.rb +129 -0
  63. data/lib/facturx/writer/stage_support/structure.rb +88 -0
  64. data/lib/facturx/writer/stages/document_context.rb +31 -0
  65. data/lib/facturx/writer/stages/exchanged_document.rb +32 -0
  66. data/lib/facturx/writer/stages/trade_agreement/parties.rb +105 -0
  67. data/lib/facturx/writer/stages/trade_agreement/references.rb +92 -0
  68. data/lib/facturx/writer/stages/trade_agreement.rb +45 -0
  69. data/lib/facturx/writer/stages/trade_delivery.rb +77 -0
  70. data/lib/facturx/writer/stages/trade_lines/agreement.rb +92 -0
  71. data/lib/facturx/writer/stages/trade_lines/product.rb +78 -0
  72. data/lib/facturx/writer/stages/trade_lines/quantity.rb +17 -0
  73. data/lib/facturx/writer/stages/trade_lines/settlement.rb +89 -0
  74. data/lib/facturx/writer/stages/trade_lines.rb +67 -0
  75. data/lib/facturx/writer/stages/trade_settlement/parties.rb +33 -0
  76. data/lib/facturx/writer/stages/trade_settlement/payment.rb +97 -0
  77. data/lib/facturx/writer/stages/trade_settlement/summary.rb +88 -0
  78. data/lib/facturx/writer/stages/trade_settlement/taxes.rb +121 -0
  79. data/lib/facturx/writer/stages/trade_settlement.rb +70 -0
  80. data/lib/facturx/writer.rb +68 -0
  81. data/lib/facturx/xml/namespaces.rb +19 -0
  82. data/lib/facturx/xml/profile_detector.rb +2 -4
  83. data/lib/facturx.rb +42 -0
  84. data/rbi/facturx.rbi +1090 -0
  85. metadata +79 -4
@@ -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
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
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'attach'
4
+ require_relative 'profile_resolver'
5
+ require_relative 'writer'
6
+
7
+ module Facturx
8
+ class Generate
9
+ def initialize(writer: Writer.new, attacher: Attach.new, profile_resolver: ProfileResolver.new)
10
+ @writer = writer
11
+ @attacher = attacher
12
+ @profile_resolver = profile_resolver
13
+ end
14
+
15
+ def call(pdf:, document:, profile:)
16
+ canonical_profile = @profile_resolver.call(profile)
17
+ xml = @writer.call(document:, profile: canonical_profile)
18
+ @attacher.call(pdf:, xml:)
19
+ end
20
+ end
21
+ 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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error'
4
+ require_relative 'profile'
5
+ require_relative 'profiles'
6
+
7
+ module Facturx
8
+ class ProfileResolver
9
+ def initialize(profiles: Profiles)
10
+ @profiles = profiles
11
+ end
12
+
13
+ def call(profile)
14
+ canonical = resolve(profile)
15
+ return canonical unless profile.is_a?(Profile) && !profile.equal?(canonical)
16
+
17
+ unsupported!(profile)
18
+ rescue KeyError
19
+ unsupported!(profile)
20
+ end
21
+
22
+ private
23
+
24
+ def resolve(profile)
25
+ case profile
26
+ when Symbol then @profiles.fetch(profile)
27
+ when Profile
28
+ return unsupported!(profile) unless profile.id.is_a?(Symbol)
29
+
30
+ @profiles.fetch(profile.id)
31
+ else unsupported!(profile)
32
+ end
33
+ end
34
+
35
+ def unsupported!(profile)
36
+ identifier = profile.respond_to?(:id) ? profile.id : profile
37
+ raise UnsupportedProfileError.new('Factur-X profile is unsupported', profile: identifier)
38
+ end
39
+ end
40
+ 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