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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ class Reader
5
+ class SemanticMapper
6
+ private
7
+
8
+ def reference(id, context: @document, base_xpath: nil, field: :id)
9
+ item = value(id, context:, base_xpath:)
10
+ DocumentReference.new(**{ field => item }) if item
11
+ end
12
+
13
+ def identifier(value_id, scheme_id = nil, context: @document, base_xpath: nil)
14
+ return unless value_id
15
+
16
+ item = value(value_id, context:, base_xpath:)
17
+ unless item
18
+ @terms.mark_value(scheme_id, context:, base_xpath:) if scheme_id
19
+ return
20
+ end
21
+
22
+ scheme = value(scheme_id, context:, base_xpath:) if scheme_id
23
+ Identifier.new(value: item, scheme_id: scheme)
24
+ end
25
+
26
+ def quantity(value_id, unit_id, context:, base_xpath:)
27
+ amount = value(value_id, context:, base_xpath:)
28
+ unless amount
29
+ @terms.mark_value(unit_id, context:, base_xpath:)
30
+ return
31
+ end
32
+
33
+ unit = value(unit_id, context:, base_xpath:)
34
+ Quantity.new(value: amount, unit_code: unit)
35
+ end
36
+
37
+ def tax_registration(attribute, scheme_id, node, base, group_id)
38
+ term = term_for(:party, group_id, attribute)
39
+ return unless term
40
+
41
+ nodes = node.xpath("./ram:SpecifiedTaxRegistration/ram:ID[@schemeID='#{scheme_id}']", NAMESPACES)
42
+ item = value(term.id, context: node, base_xpath: base, nodes:)
43
+ Identifier.new(value: item, scheme_id:) if item
44
+ end
45
+
46
+ def read_price_allowance_indicator(node, base)
47
+ allowance = node.at_xpath('./ram:AppliedTradeAllowanceCharge', NAMESPACES)
48
+ return unless allowance
49
+
50
+ indicator = allowance.at_xpath('./ram:ChargeIndicator', NAMESPACES)
51
+ @terms.mark(indicator)
52
+ allowance_base = "#{base}/ram:AppliedTradeAllowanceCharge"
53
+ value('BT-147-02', context: allowance, base_xpath: allowance_base)
54
+ end
55
+
56
+ def technical_value(xpath, context)
57
+ @terms.raw_value(xpath, context:)
58
+ end
59
+
60
+ def technical_boolean(xpath, context, term_id:, required: false, missing_path: xpath)
61
+ @terms.coerced_value(xpath, context:, type: :boolean, term_id:, required:, missing_path:)
62
+ end
63
+
64
+ def value(id, context: @document, base_group: nil, base_xpath: nil, nodes: nil)
65
+ return unless id
66
+
67
+ @terms.value(id, context:, base_xpath: base_xpath || (base_group && group_xpath(base_group)), nodes:)
68
+ end
69
+
70
+ def scalar_value(id, **)
71
+ Array(value(id, **)).first
72
+ end
73
+
74
+ def scalar_attributes(model, group_id, context: @document, base_xpath: nil, **selection)
75
+ only = selection[:only]
76
+ except = selection.fetch(:except, [])
77
+ model_terms(model, group_id)
78
+ .group_by(&:attribute)
79
+ .select { |attribute, terms| terms.one? && selected_attribute?(attribute, only, except) }
80
+ .to_h { |attribute, terms| [attribute, value(terms.first.id, context:, base_xpath:)] }
81
+ end
82
+
83
+ def selected_attribute?(attribute, only, except)
84
+ (only.nil? || only.include?(attribute)) && !except.include?(attribute)
85
+ end
86
+
87
+ def model_terms(model, group_id)
88
+ @terms_by_model_and_group.fetch([model, group_id], EMPTY_TERMS)
89
+ end
90
+
91
+ def term_for(model, group_id, attribute, xpath_suffix: nil)
92
+ terms = model_terms(model, group_id).select { |term| term.attribute == attribute }
93
+ terms.find { |term| xpath_suffix.nil? || term.xpath.end_with?(xpath_suffix) }
94
+ end
95
+
96
+ def term_for_attribute(model, attribute, xpath_suffix: nil)
97
+ terms = @terms_by_model_and_attribute.fetch([model, attribute], EMPTY_TERMS)
98
+ terms.find { |term| xpath_suffix.nil? || term.xpath.end_with?(xpath_suffix) }
99
+ end
100
+
101
+ def group_nodes(id, **, &)
102
+ @terms.group_nodes(id, **, &)
103
+ end
104
+
105
+ def first_group(id, **)
106
+ group_nodes(id, **).first
107
+ end
108
+
109
+ def group_xpath(id)
110
+ @registry.group(id).xpath
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ class Reader
5
+ class SemanticMapper
6
+ private
7
+
8
+ def lines
9
+ group_nodes('BG-25').map { |node| line(node) }
10
+ end
11
+
12
+ def line(node)
13
+ base = group_xpath('BG-25')
14
+ Line.new(
15
+ **scalar_attributes(:line, 'BG-25', context: node, base_xpath: base),
16
+ **line_trade_attributes(node, base),
17
+ **line_reference_attributes(node, base)
18
+ )
19
+ end
20
+
21
+ def line_trade_attributes(node, base)
22
+ {
23
+ product: product(node, base),
24
+ quantity: quantity('BT-129', 'BT-130', context: node, base_xpath: base),
25
+ gross_price: price(node, base, gross: true),
26
+ net_price: price(node, base, gross: false),
27
+ tax: line_tax(node, base),
28
+ period: period('BG-26', context: node, base_xpath: base),
29
+ allowances: allowance_charges('BG-27', charge: false, context: node, base_xpath: base),
30
+ charges: allowance_charges('BG-28', charge: true, context: node, base_xpath: base)
31
+ }
32
+ end
33
+
34
+ def line_reference_attributes(node, base)
35
+ {
36
+ buyer_order_reference: reference('BT-132', context: node, base_xpath: base, field: :line_id),
37
+ invoiced_object_identifier: line_invoiced_object_identifier(node, base)
38
+ }
39
+ end
40
+
41
+ def product(parent, parent_base)
42
+ node = first_group('BG-31', context: parent, base_xpath: parent_base)
43
+ return unless node
44
+
45
+ base = group_xpath('BG-31')
46
+ Product.new(**product_attributes_hash(node, base))
47
+ end
48
+
49
+ def product_attributes_hash(node, base)
50
+ identifiers = %i[seller_identifier buyer_identifier global_identifier]
51
+ scalar_attributes(:product, 'BG-31', context: node, base_xpath: base, except: identifiers).merge(
52
+ seller_identifier: identifier('BT-155', context: node, base_xpath: base),
53
+ buyer_identifier: identifier('BT-156', context: node, base_xpath: base),
54
+ global_identifier: identifier('BT-157', 'BT-157-1', context: node, base_xpath: base),
55
+ attributes: product_attributes(node, base),
56
+ classifications: product_classifications(node, base)
57
+ )
58
+ end
59
+
60
+ def product_attributes(parent, parent_base)
61
+ group_nodes('BG-32', context: parent, base_xpath: parent_base).map do |node|
62
+ base = group_xpath('BG-32')
63
+ ProductAttribute.new(**scalar_attributes(:product_attribute, 'BG-32', context: node, base_xpath: base))
64
+ end
65
+ end
66
+
67
+ def product_classifications(parent, parent_base)
68
+ nodes = parent.xpath('./ram:DesignatedProductClassification', NAMESPACES)
69
+ nodes.map do |node|
70
+ base = "#{parent_base}/ram:DesignatedProductClassification"
71
+ ProductClassification.new(
72
+ code: scalar_value('BT-158', context: node, base_xpath: base),
73
+ list_id: value('BT-158-1', context: node, base_xpath: base),
74
+ list_version_id: value('BT-158-2', context: node, base_xpath: base)
75
+ )
76
+ end
77
+ end
78
+
79
+ def price(parent, parent_base, gross:)
80
+ prefix = gross ? 'Gross' : 'Net'
81
+ node = parent.at_xpath("./ram:SpecifiedLineTradeAgreement/ram:#{prefix}PriceProductTradePrice", NAMESPACES)
82
+ unless node
83
+ value('BT-146', context: parent, base_xpath: parent_base) unless gross
84
+ return
85
+ end
86
+
87
+ base = "#{parent_base}/ram:SpecifiedLineTradeAgreement/ram:#{prefix}PriceProductTradePrice"
88
+ Price.new(**price_attributes(node, base, gross))
89
+ end
90
+
91
+ def price_attributes(node, base, gross)
92
+ ids = gross ? %w[BT-148 BT-149-1 BT-150-1 BT-147] : %w[BT-146 BT-149 BT-150]
93
+ {
94
+ amount: value(ids[0], context: node, base_xpath: base),
95
+ basis_quantity: quantity(ids[1], ids[2], context: node, base_xpath: base),
96
+ discount: gross ? price_discount(node, base, ids[3]) : nil
97
+ }
98
+ end
99
+
100
+ def price_discount(node, base, amount_id)
101
+ amount = value(amount_id, context: node, base_xpath: base)
102
+ indicator = read_price_allowance_indicator(node, base)
103
+ return amount if indicator == false
104
+ return unless indicator == true
105
+
106
+ @terms.add(:invalid_value, 'BT-147-02', base, 'Gross price discount cannot be a charge', value: indicator)
107
+ nil
108
+ end
109
+
110
+ def line_tax(parent, parent_base)
111
+ node = first_group('BG-30', context: parent, base_xpath: parent_base)
112
+ return unless node
113
+
114
+ base = group_xpath('BG-30')
115
+ attributes = scalar_attributes(:tax_breakdown, 'BG-30', context: node, base_xpath: base)
116
+ TaxBreakdown.new(type_code: technical_value('./ram:TypeCode', node), **attributes)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ class Reader
5
+ class SemanticMapper
6
+ private
7
+
8
+ def line_invoiced_object_identifier(parent, parent_base)
9
+ relative_path = './ram:SpecifiedLineTradeSettlement/ram:AdditionalReferencedDocument'
10
+ node = parent.at_xpath("#{relative_path}[ram:TypeCode='130']", NAMESPACES)
11
+ return unless node
12
+
13
+ technical_value('./ram:TypeCode', node)
14
+ base = "#{parent_base}/ram:SpecifiedLineTradeSettlement/ram:AdditionalReferencedDocument"
15
+ identifier('BT-128', 'BT-128-1', context: node, base_xpath: base)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ class Reader
5
+ class SemanticMapper
6
+ private
7
+
8
+ def party(group_id, contact_group: nil, address_group: nil)
9
+ node = first_group(group_id)
10
+ return unless node
11
+
12
+ base = group_xpath(group_id)
13
+ Party.new(**party_scalar_attributes(node, base, group_id),
14
+ **party_identifier_attributes(node, base, group_id),
15
+ **party_group_attributes(node, base, contact_group, address_group))
16
+ end
17
+
18
+ def party_scalar_attributes(node, base, group_id)
19
+ scalar_attributes(:party, group_id, context: node, base_xpath: base,
20
+ only: %i[name description trading_name])
21
+ end
22
+
23
+ def party_identifier_attributes(node, base, group_id)
24
+ {
25
+ identifiers: party_identifiers(node, base, group_id),
26
+ legal_registration: compound_identifier(:legal_registration, node, base, group_id),
27
+ vat_identifier: tax_registration(:vat_identifier, 'VA', node, base, group_id),
28
+ tax_identifier: tax_registration(:tax_identifier, 'FC', node, base, group_id),
29
+ electronic_address: compound_identifier(:electronic_address, node, base, group_id)
30
+ }
31
+ end
32
+
33
+ def party_group_attributes(node, base, contact_group, address_group)
34
+ {
35
+ address: address_group && address(node, base, address_group),
36
+ contact: contact_group && contact(node, base, contact_group)
37
+ }
38
+ end
39
+
40
+ def party_identifiers(node, base, group_id)
41
+ value_term = term_for(:party, group_id, :identifiers, xpath_suffix: '/ram:ID')
42
+ return [] unless value_term
43
+
44
+ identifiers = local_party_identifiers(value_term, node, base)
45
+ scheme_term = term_for(:party, group_id, :identifiers, xpath_suffix: '/@schemeID')
46
+ return identifiers unless scheme_term
47
+
48
+ identifiers.concat(global_party_identifiers(node))
49
+ end
50
+
51
+ def local_party_identifiers(term, node, base)
52
+ Array(value(term.id, context: node, base_xpath: base)).compact.map { |item| Identifier.new(value: item) }
53
+ end
54
+
55
+ def global_party_identifiers(node)
56
+ node.xpath('./ram:GlobalID', NAMESPACES).map do |global_id|
57
+ @terms.mark(global_id)
58
+ Identifier.new(value: global_id.text, scheme_id: global_id['schemeID'])
59
+ end
60
+ end
61
+
62
+ def compound_identifier(attribute, node, base, group_id)
63
+ value_term = term_for(:party, group_id, attribute, xpath_suffix: '/ram:ID') ||
64
+ term_for(:party, group_id, attribute, xpath_suffix: '/ram:URIID')
65
+ return unless value_term
66
+
67
+ scheme_term = term_for(:party, group_id, attribute, xpath_suffix: '/@schemeID')
68
+ identifier(value_term.id, scheme_term&.id, context: node, base_xpath: base)
69
+ end
70
+
71
+ def contact(parent, parent_base, group_id)
72
+ node = first_group(group_id, context: parent, base_xpath: parent_base)
73
+ return unless node
74
+
75
+ base = group_xpath(group_id)
76
+ attributes = scalar_attributes(:contact, group_id, context: node, base_xpath: base)
77
+ Contact.new(**attributes)
78
+ end
79
+
80
+ def address(parent, parent_base, group_id)
81
+ node = first_group(group_id, context: parent, base_xpath: parent_base)
82
+ return unless node
83
+
84
+ base = group_xpath(group_id)
85
+ attributes = scalar_attributes(:address, group_id, context: node, base_xpath: base)
86
+ Address.new(**attributes)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ class Reader
5
+ class SemanticMapper
6
+ private
7
+
8
+ def payment
9
+ nodes = group_nodes('BG-16', collapse_if: method(:projected_credit_transfers?))
10
+ base = group_xpath('BG-16')
11
+ attributes = settlement_payment_attributes(nodes.first, base)
12
+ if nodes.any?
13
+ attributes.merge!(payment_means_attributes(nodes, base,
14
+ projected: projected_credit_transfers?(nodes)))
15
+ end
16
+ return if attributes.empty?
17
+
18
+ PaymentInstructions.new(**attributes)
19
+ end
20
+
21
+ def settlement_payment_attributes(payment_means, base)
22
+ attributes = scalar_attributes(:payment_instructions, 'BG-19')
23
+ direct_debit_details = direct_debit(payment_means, base)
24
+ attributes[:direct_debit] = direct_debit_details if direct_debit_details
25
+ attributes.compact
26
+ end
27
+
28
+ def payment_means_attributes(nodes, base, projected:)
29
+ attributes = scalar_attributes(:payment_instructions, 'BG-16', context: nodes.first, base_xpath: base)
30
+ payment_nodes = projected ? nodes : [nodes.first]
31
+ mark_repeated_payment_attributes(payment_nodes.drop(1), base) if projected
32
+ attributes.merge(
33
+ credit_transfers: credit_transfers(payment_nodes, base),
34
+ payment_card: payment_nodes.filter_map { |node| payment_card(node, base) }.first
35
+ )
36
+ end
37
+
38
+ def mark_repeated_payment_attributes(nodes, base)
39
+ %w[BT-81 BT-82].each do |term_id|
40
+ nodes.each { |node| @terms.mark_value(term_id, context: node, base_xpath: base) }
41
+ end
42
+ end
43
+
44
+ def projected_credit_transfers?(nodes)
45
+ return false unless nodes.size > 1
46
+ return false unless nodes.all? { |node| payment_account_count(node) == 1 }
47
+
48
+ repeated_payment_attributes?(nodes) && nodes.drop(1).all? { |node| supplemental_payment_node?(node) }
49
+ end
50
+
51
+ def repeated_payment_attributes?(nodes)
52
+ %w[ram:TypeCode ram:Information].all? do |xpath|
53
+ nodes.map { |node| node.at_xpath("./#{xpath}", NAMESPACES)&.text }.uniq.one?
54
+ end
55
+ end
56
+
57
+ def supplemental_payment_node?(node)
58
+ allowed = %w[TypeCode Information PayeePartyCreditorFinancialAccount
59
+ PayeeSpecifiedCreditorFinancialInstitution]
60
+ node.element_children.all? { |child| allowed.include?(child.name) }
61
+ end
62
+
63
+ def payment_account_count(node)
64
+ node.xpath('./ram:PayeePartyCreditorFinancialAccount', NAMESPACES).size
65
+ end
66
+
67
+ def credit_transfers(parents, parent_base)
68
+ provider = term_for(:credit_transfer, 'BG-16', :provider_identifier)
69
+ parents.flat_map do |parent|
70
+ provider_identifier = identifier(provider&.id, context: parent, base_xpath: parent_base)
71
+ group_nodes('BG-17', context: parent, base_xpath: parent_base).map do |node|
72
+ credit_transfer(node, provider_identifier)
73
+ end
74
+ end
75
+ end
76
+
77
+ def credit_transfer(node, provider_identifier)
78
+ base = group_xpath('BG-17')
79
+ account = term_for(:credit_transfer, 'BG-17', :account_identifier)
80
+ attributes = scalar_attributes(:credit_transfer, 'BG-17', context: node, base_xpath: base,
81
+ except: [:account_identifier])
82
+ CreditTransfer.new(**attributes, account_identifier: identifier(account.id, context: node, base_xpath: base),
83
+ provider_identifier:)
84
+ end
85
+
86
+ def payment_card(parent, parent_base)
87
+ node = first_group('BG-18', context: parent, base_xpath: parent_base)
88
+ return unless node
89
+
90
+ base = group_xpath('BG-18')
91
+ PaymentCard.new(**scalar_attributes(:payment_card, 'BG-18', context: node, base_xpath: base))
92
+ end
93
+
94
+ def direct_debit(payment_means, base)
95
+ values = %i[mandate_identifier creditor_identifier].to_h do |attribute|
96
+ [attribute, identifier(term_for_attribute(:direct_debit, attribute)&.id)]
97
+ end
98
+ if payment_means
99
+ values[:debtor_account_identifier] = identifier(
100
+ term_for(:direct_debit, 'BG-16', :debtor_account_identifier)&.id,
101
+ context: payment_means, base_xpath: base
102
+ )
103
+ end
104
+ DirectDebit.new(**values) if values.values.any?
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ class Reader
5
+ class SemanticMapper
6
+ private
7
+
8
+ def read_tax_breakdowns
9
+ nodes = group_nodes('BG-23')
10
+ base = group_xpath('BG-23')
11
+ vat_point_date = nil
12
+ items = nodes.map do |node|
13
+ tax_point_date = value('BT-7', context: node, base_xpath: base)
14
+ vat_point_date ||= tax_point_date
15
+ attributes = scalar_attributes(:tax_breakdown, 'BG-23', context: node, base_xpath: base)
16
+ TaxBreakdown.new(type_code: technical_value('./ram:TypeCode', node), **attributes)
17
+ end
18
+ TaxBreakdownResult.new(items:, vat_point_date:)
19
+ end
20
+
21
+ def allowance_charges(group_id, charge:, context: @document, base_xpath: nil)
22
+ group_nodes(group_id, context:, base_xpath:) { |node| charge_indicator(node, group_id) == charge }.map do |node|
23
+ base = group_xpath(group_id)
24
+ AllowanceCharge.new(**allowance_attributes(node, base, group_id), indicator: charge,
25
+ tax: allowance_tax(node, base, group_id))
26
+ end
27
+ end
28
+
29
+ def allowance_attributes(node, base, group_id)
30
+ scalar_attributes(:allowance_charge, group_id, context: node, base_xpath: base, except: [:tax])
31
+ end
32
+
33
+ def allowance_tax(node, base, group_id)
34
+ technical_value('./ram:CategoryTradeTax/ram:TypeCode', node)
35
+ category = value(term_for(:allowance_charge, group_id, :tax,
36
+ xpath_suffix: '/ram:CategoryTradeTax/ram:CategoryCode')&.id,
37
+ context: node, base_xpath: base)
38
+ rate = value(term_for(:allowance_charge, group_id, :tax,
39
+ xpath_suffix: '/ram:CategoryTradeTax/ram:RateApplicablePercent')&.id,
40
+ context: node, base_xpath: base)
41
+ TaxBreakdown.new(category_code: category, rate:) if category || rate
42
+ end
43
+
44
+ def charge_indicator(node, group_id)
45
+ @charge_indicators ||= {}.compare_by_identity
46
+ @charge_indicators.fetch(node) do
47
+ path = './ram:ChargeIndicator/udt:Indicator'
48
+ @charge_indicators[node] = technical_boolean(
49
+ path, node, term_id: "#{group_id}-1", required: true, missing_path: group_xpath("#{group_id}-1")
50
+ )
51
+ end
52
+ end
53
+
54
+ def period(group_id, context: @document, base_xpath: nil)
55
+ node = first_group(group_id, context:, base_xpath:)
56
+ return unless node
57
+
58
+ base = group_xpath(group_id)
59
+ Period.new(**scalar_attributes(:period, group_id, context: node, base_xpath: base))
60
+ end
61
+
62
+ def totals(currency, tax_currency)
63
+ node = first_group('BG-22')
64
+ return unless node
65
+
66
+ base = group_xpath('BG-22')
67
+ attributes = scalar_attributes(:totals, 'BG-22', context: node, base_xpath: base,
68
+ except: %i[tax_total tax_total_in_tax_currency])
69
+ Totals.new(**attributes, **tax_total_attributes(node, base, currency, tax_currency))
70
+ end
71
+
72
+ def tax_total_attributes(node, base, currency, tax_currency)
73
+ total_nodes = node.xpath('./ram:TaxTotalAmount', NAMESPACES)
74
+ invoice_tax = total_nodes.select { |item| tax_currency.nil? || item['currencyID'] == currency }
75
+ accounting_tax = total_nodes.select { |item| tax_currency && item['currencyID'] == tax_currency }
76
+ {
77
+ tax_total: value('BT-110', context: node, base_xpath: base, nodes: invoice_tax),
78
+ tax_total_in_tax_currency: value('BT-111', context: node, base_xpath: base, nodes: accounting_tax)
79
+ }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ class Reader
5
+ class SemanticMapper
6
+ EMPTY_TERMS = [].freeze
7
+ TaxBreakdownResult = Data.define(:items, :vat_point_date)
8
+
9
+ def initialize(document:, profile:, registry:, coercer:, diagnostics:)
10
+ @document = document
11
+ @profile = profile
12
+ @terms = TermReader.new(document:, profile:, registry:, coercer:, diagnostics:)
13
+ @registry = registry
14
+ active_terms = registry.for_profile(profile)
15
+ @terms_by_model_and_group = active_terms.group_by { |term| [term.model, term.group_id] }.freeze
16
+ @terms_by_model_and_attribute = active_terms.group_by { |term| [term.model, term.attribute] }.freeze
17
+ end
18
+
19
+ def call
20
+ tax_breakdowns = read_tax_breakdowns
21
+ attributes = document_attributes(tax_breakdowns).merge(
22
+ party_attributes, transaction_attributes, collection_attributes(tax_breakdowns.items)
23
+ )
24
+ attributes[:tax_currency] ||= minimum_tax_currency(attributes[:currency])
25
+ attributes[:totals] = totals(attributes[:currency], attributes[:tax_currency])
26
+ Document.new(**attributes)
27
+ end
28
+
29
+ def add_unmapped(root)
30
+ @terms.add_unmapped(root)
31
+ end
32
+
33
+ private
34
+
35
+ def minimum_tax_currency(currency)
36
+ return unless @profile.id == :minimum
37
+
38
+ nodes = @document.xpath("#{group_xpath('BG-22')}/ram:TaxTotalAmount", NAMESPACES)
39
+ nodes.filter_map { |node| node['currencyID'] }.find { |value| value != currency }
40
+ end
41
+
42
+ def party_attributes
43
+ {
44
+ seller: party('BG-4', contact_group: 'BG-6', address_group: 'BG-5'),
45
+ buyer: party('BG-7', contact_group: 'BG-9', address_group: 'BG-8'),
46
+ payee: party('BG-10'),
47
+ tax_representative: party('BG-11', address_group: 'BG-12')
48
+ }
49
+ end
50
+
51
+ def transaction_attributes
52
+ {
53
+ delivery: delivery, billing_period: period('BG-14'), payment: payment
54
+ }
55
+ end
56
+
57
+ def collection_attributes(tax_breakdowns)
58
+ {
59
+ notes: notes, lines: lines, tax_breakdowns:,
60
+ allowances: allowance_charges('BG-20', charge: false),
61
+ charges: allowance_charges('BG-21', charge: true),
62
+ preceding_invoices: preceding_invoices, supporting_documents: supporting_documents
63
+ }
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ require_relative 'semantic_mapper/document_mapping'
70
+ require_relative 'semantic_mapper/party_mapping'
71
+ require_relative 'semantic_mapper/delivery_mapping'
72
+ require_relative 'semantic_mapper/payment_mapping'
73
+ require_relative 'semantic_mapper/line_mapping'
74
+ require_relative 'semantic_mapper/line_references'
75
+ require_relative 'semantic_mapper/trade_mapping'
76
+ require_relative 'semantic_mapper/helpers'
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ class Reader
5
+ class TermReader
6
+ def coerced_value(xpath, context:, type:, term_id: nil, **options)
7
+ node = context.at_xpath(xpath, NAMESPACES)
8
+ return diagnose_raw_missing(options.fetch(:missing_path, xpath), term_id) if node.nil? && options[:required]
9
+ return unless node
10
+
11
+ coerce_raw_value(node, type:, term_id:)
12
+ rescue CoercionError => e
13
+ add(:invalid_value, term_id, node.path, e.message, e.details)
14
+ nil
15
+ end
16
+
17
+ private
18
+
19
+ def coerce_raw_value(node, type:, term_id:)
20
+ mark(node)
21
+ return diagnose_raw_empty(node, term_id) if node.text.strip.empty?
22
+
23
+ @coercer.call(node.text, type:, term_id:, path: node.path)
24
+ end
25
+
26
+ def coerce_term_value(node, term)
27
+ @coercer.call(node.text, type: term.type, scale: term.scale, term_id: term.id, path: node.path)
28
+ end
29
+
30
+ def coerce(node, term)
31
+ return diagnose_date_format(node, term) if invalid_date_format?(node, term)
32
+ return diagnose_empty(node, term) if node.text.strip.empty?
33
+
34
+ coerce_term_value(node, term)
35
+ rescue CoercionError => e
36
+ add(:invalid_value, term.id, node.path, e.message, e.details)
37
+ nil
38
+ end
39
+
40
+ def diagnose_empty(node, term)
41
+ add(:empty_value, term.id, node.path, 'Value is empty', value: node.text)
42
+ nil
43
+ end
44
+
45
+ def diagnose_raw_empty(node, term_id)
46
+ add(:empty_value, term_id, node.path, 'Value is empty', value: node.text)
47
+ nil
48
+ end
49
+
50
+ def diagnose_raw_missing(path, term_id)
51
+ add(:missing_required_term, term_id, path, 'Required value is missing')
52
+ nil
53
+ end
54
+
55
+ def invalid_date_format?(node, term)
56
+ return false unless term.type == :date_102
57
+
58
+ format = node.attribute('format')
59
+ mark(format)
60
+ format&.value != '102'
61
+ end
62
+
63
+ def diagnose_date_format(node, term)
64
+ add(:invalid_value, term.id, node.path, 'Date format must be 102', format: node['format'])
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end