facturx 0.2.0 → 1.1.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -3
  3. data/DOCUMENTATION.md +271 -0
  4. data/README.md +79 -69
  5. data/lib/facturx/attach.rb +4 -20
  6. data/lib/facturx/builders/association.rb +8 -0
  7. data/lib/facturx/builders/base.rb +129 -0
  8. data/lib/facturx/builders/schema.rb +112 -0
  9. data/lib/facturx/builders.rb +25 -0
  10. data/lib/facturx/error.rb +7 -1
  11. data/lib/facturx/format.rb +85 -0
  12. data/lib/facturx/generate.rb +21 -0
  13. data/lib/facturx/pdf/composer.rb +32 -0
  14. data/lib/facturx/profile.rb +1 -1
  15. data/lib/facturx/profile_resolver.rb +40 -0
  16. data/lib/facturx/profiles.rb +1 -8
  17. data/lib/facturx/reader/semantic_mapper/line_mapping.rb +1 -1
  18. data/lib/facturx/reader/semantic_mapper/line_references.rb +19 -0
  19. data/lib/facturx/reader/semantic_mapper/payment_mapping.rb +48 -11
  20. data/lib/facturx/reader/semantic_mapper.rb +9 -0
  21. data/lib/facturx/reader/term_reader.rb +12 -4
  22. data/lib/facturx/reader.rb +3 -7
  23. data/lib/facturx/source_reader.rb +1 -1
  24. data/lib/facturx/validation.rb +61 -0
  25. data/lib/facturx/version.rb +1 -1
  26. data/lib/facturx/writer/context.rb +53 -0
  27. data/lib/facturx/writer/stage.rb +47 -0
  28. data/lib/facturx/writer/stage_support/contact_details.rb +57 -0
  29. data/lib/facturx/writer/stage_support/emission.rb +78 -0
  30. data/lib/facturx/writer/stage_support/identifiers.rb +129 -0
  31. data/lib/facturx/writer/stage_support/structure.rb +88 -0
  32. data/lib/facturx/writer/stages/document_context.rb +31 -0
  33. data/lib/facturx/writer/stages/exchanged_document.rb +32 -0
  34. data/lib/facturx/writer/stages/trade_agreement/parties.rb +105 -0
  35. data/lib/facturx/writer/stages/trade_agreement/references.rb +92 -0
  36. data/lib/facturx/writer/stages/trade_agreement.rb +45 -0
  37. data/lib/facturx/writer/stages/trade_delivery.rb +77 -0
  38. data/lib/facturx/writer/stages/trade_lines/agreement.rb +92 -0
  39. data/lib/facturx/writer/stages/trade_lines/product.rb +78 -0
  40. data/lib/facturx/writer/stages/trade_lines/quantity.rb +17 -0
  41. data/lib/facturx/writer/stages/trade_lines/settlement.rb +89 -0
  42. data/lib/facturx/writer/stages/trade_lines.rb +67 -0
  43. data/lib/facturx/writer/stages/trade_settlement/parties.rb +33 -0
  44. data/lib/facturx/writer/stages/trade_settlement/payment.rb +97 -0
  45. data/lib/facturx/writer/stages/trade_settlement/summary.rb +88 -0
  46. data/lib/facturx/writer/stages/trade_settlement/taxes.rb +121 -0
  47. data/lib/facturx/writer/stages/trade_settlement.rb +70 -0
  48. data/lib/facturx/writer/tracker.rb +131 -0
  49. data/lib/facturx/writer.rb +76 -0
  50. data/lib/facturx/xml/namespaces.rb +19 -0
  51. data/lib/facturx/xml/parser.rb +1 -1
  52. data/lib/facturx/xml/profile_detector.rb +2 -4
  53. data/lib/facturx/xml/report_builder.rb +44 -0
  54. data/lib/facturx/xml/schema_registry.rb +22 -0
  55. data/lib/facturx/xml/schema_validator.rb +7 -2
  56. data/lib/facturx/xml/validator.rb +32 -0
  57. data/lib/facturx/xml/verifier.rb +16 -11
  58. data/lib/facturx.rb +41 -4
  59. data/rbi/facturx.rbi +1755 -0
  60. metadata +44 -4
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ module Builders
5
+ class Base
6
+ UNSET = Object.new.freeze
7
+ private_constant :UNSET
8
+
9
+ class << self
10
+ attr_reader :model, :associations
11
+
12
+ def configure(model, associations)
13
+ @model = model
14
+ @associations = associations
15
+ define_attribute_methods
16
+ self
17
+ end
18
+
19
+ def build(**attributes, &block)
20
+ builder = new(**attributes)
21
+ block&.call(builder)
22
+ builder.build
23
+ end
24
+
25
+ private
26
+
27
+ def define_attribute_methods
28
+ model.members.each do |attribute|
29
+ association = associations[attribute]
30
+ association ? define_association_methods(attribute, association) : define_scalar_methods(attribute)
31
+ end
32
+ end
33
+
34
+ def define_scalar_methods(attribute)
35
+ define_method(attribute) { @attributes[attribute] }
36
+ define_method(:"#{attribute}=") { |value| @attributes[attribute] = value }
37
+ end
38
+
39
+ def define_association_methods(attribute, association)
40
+ if association.collection
41
+ define_collection_helper(attribute, association)
42
+ else
43
+ define_singular_helper(attribute, association)
44
+ end
45
+ define_method(:"#{attribute}=") { |value| set_association(attribute, association, value) }
46
+ end
47
+
48
+ def define_singular_helper(attribute, association)
49
+ define_method(attribute) do |value = UNSET, **attributes, &block|
50
+ assign_association(attribute, association, value, attributes, block)
51
+ end
52
+ end
53
+
54
+ def define_collection_helper(attribute, association)
55
+ define_method(association.helper) do |value = UNSET, **attributes, &block|
56
+ append_association(attribute, association, value, attributes, block)
57
+ end
58
+ define_method(attribute) { @attributes.fetch(attribute, []).dup.freeze }
59
+ end
60
+ end
61
+
62
+ def initialize(**attributes)
63
+ unknown = attributes.keys - self.class.model.members
64
+ raise ArgumentError, "Unknown attributes: #{unknown.map(&:inspect).join(', ')}" unless unknown.empty?
65
+
66
+ @attributes = {}
67
+ attributes.each { |attribute, value| public_send(:"#{attribute}=", value) }
68
+ end
69
+
70
+ def build
71
+ self.class.model.new(**@attributes)
72
+ end
73
+
74
+ private
75
+
76
+ def assign_association(attribute, association, value, attributes, block)
77
+ if @attributes.key?(attribute)
78
+ raise ArgumentError,
79
+ "#{attribute} is already set; use #{attribute}= to replace it"
80
+ end
81
+
82
+ object = association_value(attribute, association, value, attributes, block)
83
+ @attributes[attribute] = object
84
+ end
85
+
86
+ def append_association(attribute, association, value, attributes, block)
87
+ object = association_value(association.helper, association, value, attributes, block)
88
+ (@attributes[attribute] ||= []) << object
89
+ object
90
+ end
91
+
92
+ def set_association(attribute, association, value)
93
+ if association.collection
94
+ set_collection(attribute, association, value)
95
+ else
96
+ validate_object!(attribute, association.model, value) unless value.nil?
97
+ @attributes[attribute] = value
98
+ end
99
+ end
100
+
101
+ def set_collection(attribute, association, value)
102
+ raise TypeError, "#{attribute} must be an Array" unless value.is_a?(Array)
103
+
104
+ value.each { |object| validate_object!(attribute, association.model, object) }
105
+ @attributes[attribute] = value.dup
106
+ end
107
+
108
+ def association_value(name, association, value, attributes, block)
109
+ forms = [!value.equal?(UNSET), !attributes.empty?, !block.nil?]
110
+ unless forms.one?
111
+ raise ArgumentError, "#{name} accepts exactly one of an object, keyword attributes, or a block"
112
+ end
113
+
114
+ return validate_object!(name, association.model, value) unless value.equal?(UNSET)
115
+
116
+ builder = Builders.for(association.model)
117
+ attributes.empty? ? builder.build(&block) : builder.build(**attributes)
118
+ end
119
+
120
+ def validate_object!(attribute, model, object)
121
+ unless object.instance_of?(model) && object.frozen?
122
+ raise TypeError, "#{attribute} must be an immutable #{model.name}"
123
+ end
124
+
125
+ object
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'association'
4
+
5
+ module Facturx
6
+ module Builders
7
+ module Schema
8
+ module_function
9
+
10
+ def singular(model)
11
+ Association.new(model:, collection: false, helper: nil)
12
+ end
13
+
14
+ def collection(model, helper)
15
+ Association.new(model:, collection: true, helper:)
16
+ end
17
+
18
+ ASSOCIATIONS = {
19
+ Note => {},
20
+ Identifier => {},
21
+ Period => {},
22
+ Quantity => {},
23
+ Contact => {},
24
+ Address => {},
25
+ Party => {
26
+ identifiers: collection(Identifier, :identifier),
27
+ legal_registration: singular(Identifier),
28
+ address: singular(Address),
29
+ vat_identifier: singular(Identifier),
30
+ tax_identifier: singular(Identifier),
31
+ electronic_address: singular(Identifier),
32
+ contact: singular(Contact)
33
+ },
34
+ CreditTransfer => {
35
+ account_identifier: singular(Identifier),
36
+ provider_identifier: singular(Identifier)
37
+ },
38
+ PaymentCard => {},
39
+ DirectDebit => {
40
+ mandate_identifier: singular(Identifier),
41
+ creditor_identifier: singular(Identifier),
42
+ debtor_account_identifier: singular(Identifier)
43
+ },
44
+ PaymentInstructions => {
45
+ credit_transfers: collection(CreditTransfer, :credit_transfer),
46
+ payment_card: singular(PaymentCard),
47
+ direct_debit: singular(DirectDebit)
48
+ },
49
+ DocumentReference => {},
50
+ Delivery => {
51
+ location_identifier: singular(Identifier),
52
+ party: singular(Party)
53
+ },
54
+ SupportingDocument => {},
55
+ ProductAttribute => {},
56
+ ProductClassification => {},
57
+ Product => {
58
+ seller_identifier: singular(Identifier),
59
+ buyer_identifier: singular(Identifier),
60
+ global_identifier: singular(Identifier),
61
+ attributes: collection(ProductAttribute, :attribute),
62
+ classifications: collection(ProductClassification, :classification)
63
+ },
64
+ Price => {
65
+ basis_quantity: singular(Quantity)
66
+ },
67
+ TaxBreakdown => {},
68
+ AllowanceCharge => {
69
+ tax: singular(TaxBreakdown)
70
+ },
71
+ Totals => {},
72
+ Line => {
73
+ product: singular(Product),
74
+ quantity: singular(Quantity),
75
+ gross_price: singular(Price),
76
+ net_price: singular(Price),
77
+ tax: singular(TaxBreakdown),
78
+ period: singular(Period),
79
+ allowances: collection(AllowanceCharge, :allowance),
80
+ charges: collection(AllowanceCharge, :charge),
81
+ buyer_order_reference: singular(DocumentReference),
82
+ invoiced_object_identifier: singular(Identifier)
83
+ },
84
+ Document => {
85
+ project_reference: singular(DocumentReference),
86
+ contract_reference: singular(DocumentReference),
87
+ purchase_order_reference: singular(DocumentReference),
88
+ sales_order_reference: singular(DocumentReference),
89
+ receiving_advice_reference: singular(DocumentReference),
90
+ despatch_advice_reference: singular(DocumentReference),
91
+ tender_or_lot_reference: singular(DocumentReference),
92
+ invoiced_object_identifier: singular(Identifier),
93
+ seller: singular(Party),
94
+ buyer: singular(Party),
95
+ payee: singular(Party),
96
+ tax_representative: singular(Party),
97
+ delivery: singular(Delivery),
98
+ billing_period: singular(Period),
99
+ payment: singular(PaymentInstructions),
100
+ totals: singular(Totals),
101
+ notes: collection(Note, :note),
102
+ lines: collection(Line, :line),
103
+ tax_breakdowns: collection(TaxBreakdown, :tax_breakdown),
104
+ allowances: collection(AllowanceCharge, :allowance),
105
+ charges: collection(AllowanceCharge, :charge),
106
+ preceding_invoices: collection(DocumentReference, :preceding_invoice),
107
+ supporting_documents: collection(SupportingDocument, :supporting_document)
108
+ }
109
+ }.transform_values(&:freeze).freeze
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'document'
4
+ require_relative 'builders/schema'
5
+ require_relative 'builders/base'
6
+
7
+ module Facturx
8
+ module Builders
9
+ BUILDERS = Schema::ASSOCIATIONS.to_h do |model, associations|
10
+ name = :"#{model.name.delete_prefix('Facturx::')}Builder"
11
+ [model, const_set(name, Class.new(Base).configure(model, associations))]
12
+ end.freeze
13
+ private_constant :BUILDERS
14
+
15
+ module_function
16
+
17
+ def for(model)
18
+ BUILDERS.fetch(model)
19
+ end
20
+ end
21
+
22
+ Document.define_singleton_method(:build) do |**attributes, &block|
23
+ Builders::DocumentBuilder.build(**attributes, &block)
24
+ end
25
+ end
data/lib/facturx/error.rb CHANGED
@@ -10,9 +10,15 @@ module Facturx
10
10
  end
11
11
  end
12
12
 
13
- class InvalidXmlError < Error; end
13
+ class ValidationError < Error; end
14
+ class InvalidDocumentError < ValidationError; end
15
+ class InvalidXmlError < ValidationError; end
14
16
  class UnknownProfileError < InvalidXmlError; end
15
17
  class XsdValidationError < InvalidXmlError; end
18
+ class SchemaLoadError < Error; end
19
+ class InvalidSourceError < Error; end
20
+ class UnsupportedProfileError < Error; end
21
+ class FormattingError < Error; end
16
22
  class InvalidPdfError < Error; end
17
23
  class ProtectedPdfError < InvalidPdfError; end
18
24
  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 'pdf/composer'
4
+ require_relative 'profile_resolver'
5
+ require_relative 'writer'
6
+
7
+ module Facturx
8
+ class Generate
9
+ def initialize(writer: Writer.new, pdf_composer: Pdf::Composer.new, profile_resolver: ProfileResolver.new)
10
+ @writer = writer
11
+ @pdf_composer = pdf_composer
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
+ @pdf_composer.call(pdf:, xml:, profile: canonical_profile)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'inspector'
4
+ require_relative 'extractor'
5
+ require_relative 'verifier'
6
+ require_relative '../composers/ghostscript'
7
+
8
+ module Facturx
9
+ module Pdf
10
+ class Composer
11
+ def initialize(
12
+ inspector: Inspector.new,
13
+ backend: Composers::Ghostscript.new,
14
+ extractor: Extractor.new,
15
+ verifier: Verifier.new
16
+ )
17
+ @inspector = inspector
18
+ @backend = backend
19
+ @extractor = extractor
20
+ @verifier = verifier
21
+ end
22
+
23
+ def call(pdf:, xml:, profile:)
24
+ inspection = @inspector.call(pdf)
25
+ output = @backend.call(pdf:, xml:, profile:)
26
+ result = @extractor.call(output)
27
+ @verifier.call(result:, expected_xml: xml, expected_page_count: inspection.page_count, profile:)
28
+ output
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Facturx
4
- Profile = Data.define(:id, :guideline_urn, :xsd_path, :conformance_level)
4
+ Profile = Data.define(:id, :guideline_urn, :conformance_level)
5
5
  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
@@ -4,44 +4,37 @@ require_relative 'profile'
4
4
 
5
5
  module Facturx
6
6
  module Profiles
7
- SPECIFICATION_VERSION = '1.09.2'
8
- SCHEMA_ROOT = File.expand_path("schema/#{SPECIFICATION_VERSION}", __dir__).freeze
9
-
10
7
  ALL = [
11
8
  Profile.new(
12
9
  id: :minimum,
13
10
  guideline_urn: 'urn:factur-x.eu:1p0:minimum',
14
- xsd_path: File.join(SCHEMA_ROOT, 'minimum/Factur-X_1.09.2_MINIMUM.xsd').freeze,
15
11
  conformance_level: 'MINIMUM'
16
12
  ),
17
13
  Profile.new(
18
14
  id: :basic_wl,
19
15
  guideline_urn: 'urn:factur-x.eu:1p0:basicwl',
20
- xsd_path: File.join(SCHEMA_ROOT, 'basic-wl/Factur-X_1.09.2_BASICWL.xsd').freeze,
21
16
  conformance_level: 'BASIC WL'
22
17
  ),
23
18
  Profile.new(
24
19
  id: :basic,
25
20
  guideline_urn: 'urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic',
26
- xsd_path: File.join(SCHEMA_ROOT, 'basic/Factur-X_1.09.2_BASIC.xsd').freeze,
27
21
  conformance_level: 'BASIC'
28
22
  ),
29
23
  Profile.new(
30
24
  id: :en16931,
31
25
  guideline_urn: 'urn:cen.eu:en16931:2017',
32
- xsd_path: File.join(SCHEMA_ROOT, 'en16931/Factur-X_1.09.2_EN16931.xsd').freeze,
33
26
  conformance_level: 'EN 16931'
34
27
  ),
35
28
  Profile.new(
36
29
  id: :extended,
37
30
  guideline_urn: 'urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended',
38
- xsd_path: File.join(SCHEMA_ROOT, 'extended/Factur-X_1.09.2_EXTENDED.xsd').freeze,
39
31
  conformance_level: 'EXTENDED'
40
32
  )
41
33
  ].freeze
42
34
 
43
35
  BY_ID = ALL.to_h { |profile| [profile.id, profile] }.freeze
44
36
  BY_GUIDELINE_URN = ALL.to_h { |profile| [profile.guideline_urn, profile] }.freeze
37
+ private_constant :ALL, :BY_ID, :BY_GUIDELINE_URN
45
38
 
46
39
  module_function
47
40
 
@@ -34,7 +34,7 @@ module Facturx
34
34
  def line_reference_attributes(node, base)
35
35
  {
36
36
  buyer_order_reference: reference('BT-132', context: node, base_xpath: base, field: :line_id),
37
- invoiced_object_identifier: identifier('BT-128', 'BT-128-1', context: node, base_xpath: base)
37
+ invoiced_object_identifier: line_invoiced_object_identifier(node, base)
38
38
  }
39
39
  end
40
40
 
@@ -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
@@ -6,10 +6,13 @@ module Facturx
6
6
  private
7
7
 
8
8
  def payment
9
- nodes = group_nodes('BG-16')
9
+ nodes = group_nodes('BG-16', collapse_if: method(:projected_credit_transfers?))
10
10
  base = group_xpath('BG-16')
11
11
  attributes = settlement_payment_attributes(nodes.first, base)
12
- attributes.merge!(payment_means_attributes(nodes.first, base)) if nodes.first
12
+ if nodes.any?
13
+ attributes.merge!(payment_means_attributes(nodes, base,
14
+ projected: projected_credit_transfers?(nodes)))
15
+ end
13
16
  return if attributes.empty?
14
17
 
15
18
  PaymentInstructions.new(**attributes)
@@ -22,18 +25,52 @@ module Facturx
22
25
  attributes.compact
23
26
  end
24
27
 
25
- def payment_means_attributes(node, base)
26
- provider = term_for(:credit_transfer, 'BG-16', :provider_identifier)
27
- provider_identifier = identifier(provider&.id, context: node, base_xpath: base)
28
- scalar_attributes(:payment_instructions, 'BG-16', context: node, base_xpath: base).merge(
29
- credit_transfers: credit_transfers(node, base, provider_identifier),
30
- payment_card: payment_card(node, base)
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
31
35
  )
32
36
  end
33
37
 
34
- def credit_transfers(parent, parent_base, provider_identifier)
35
- group_nodes('BG-17', context: parent, base_xpath: parent_base).map do |node|
36
- credit_transfer(node, provider_identifier)
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
37
74
  end
38
75
  end
39
76
 
@@ -21,6 +21,7 @@ module Facturx
21
21
  attributes = document_attributes(tax_breakdowns).merge(
22
22
  party_attributes, transaction_attributes, collection_attributes(tax_breakdowns.items)
23
23
  )
24
+ attributes[:tax_currency] ||= minimum_tax_currency(attributes[:currency])
24
25
  attributes[:totals] = totals(attributes[:currency], attributes[:tax_currency])
25
26
  Document.new(**attributes)
26
27
  end
@@ -31,6 +32,13 @@ module Facturx
31
32
 
32
33
  private
33
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
+
34
42
  def party_attributes
35
43
  {
36
44
  seller: party('BG-4', contact_group: 'BG-6', address_group: 'BG-5'),
@@ -63,5 +71,6 @@ require_relative 'semantic_mapper/party_mapping'
63
71
  require_relative 'semantic_mapper/delivery_mapping'
64
72
  require_relative 'semantic_mapper/payment_mapping'
65
73
  require_relative 'semantic_mapper/line_mapping'
74
+ require_relative 'semantic_mapper/line_references'
66
75
  require_relative 'semantic_mapper/trade_mapping'
67
76
  require_relative 'semantic_mapper/helpers'