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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -3
- data/DOCUMENTATION.md +271 -0
- data/README.md +79 -69
- data/lib/facturx/attach.rb +4 -20
- data/lib/facturx/builders/association.rb +8 -0
- data/lib/facturx/builders/base.rb +129 -0
- data/lib/facturx/builders/schema.rb +112 -0
- data/lib/facturx/builders.rb +25 -0
- data/lib/facturx/error.rb +7 -1
- data/lib/facturx/format.rb +85 -0
- data/lib/facturx/generate.rb +21 -0
- data/lib/facturx/pdf/composer.rb +32 -0
- data/lib/facturx/profile.rb +1 -1
- data/lib/facturx/profile_resolver.rb +40 -0
- data/lib/facturx/profiles.rb +1 -8
- data/lib/facturx/reader/semantic_mapper/line_mapping.rb +1 -1
- data/lib/facturx/reader/semantic_mapper/line_references.rb +19 -0
- data/lib/facturx/reader/semantic_mapper/payment_mapping.rb +48 -11
- data/lib/facturx/reader/semantic_mapper.rb +9 -0
- data/lib/facturx/reader/term_reader.rb +12 -4
- data/lib/facturx/reader.rb +3 -7
- data/lib/facturx/source_reader.rb +1 -1
- data/lib/facturx/validation.rb +61 -0
- data/lib/facturx/version.rb +1 -1
- data/lib/facturx/writer/context.rb +53 -0
- data/lib/facturx/writer/stage.rb +47 -0
- data/lib/facturx/writer/stage_support/contact_details.rb +57 -0
- data/lib/facturx/writer/stage_support/emission.rb +78 -0
- data/lib/facturx/writer/stage_support/identifiers.rb +129 -0
- data/lib/facturx/writer/stage_support/structure.rb +88 -0
- data/lib/facturx/writer/stages/document_context.rb +31 -0
- data/lib/facturx/writer/stages/exchanged_document.rb +32 -0
- data/lib/facturx/writer/stages/trade_agreement/parties.rb +105 -0
- data/lib/facturx/writer/stages/trade_agreement/references.rb +92 -0
- data/lib/facturx/writer/stages/trade_agreement.rb +45 -0
- data/lib/facturx/writer/stages/trade_delivery.rb +77 -0
- data/lib/facturx/writer/stages/trade_lines/agreement.rb +92 -0
- data/lib/facturx/writer/stages/trade_lines/product.rb +78 -0
- data/lib/facturx/writer/stages/trade_lines/quantity.rb +17 -0
- data/lib/facturx/writer/stages/trade_lines/settlement.rb +89 -0
- data/lib/facturx/writer/stages/trade_lines.rb +67 -0
- data/lib/facturx/writer/stages/trade_settlement/parties.rb +33 -0
- data/lib/facturx/writer/stages/trade_settlement/payment.rb +97 -0
- data/lib/facturx/writer/stages/trade_settlement/summary.rb +88 -0
- data/lib/facturx/writer/stages/trade_settlement/taxes.rb +121 -0
- data/lib/facturx/writer/stages/trade_settlement.rb +70 -0
- data/lib/facturx/writer/tracker.rb +131 -0
- data/lib/facturx/writer.rb +76 -0
- data/lib/facturx/xml/namespaces.rb +19 -0
- data/lib/facturx/xml/parser.rb +1 -1
- data/lib/facturx/xml/profile_detector.rb +2 -4
- data/lib/facturx/xml/report_builder.rb +44 -0
- data/lib/facturx/xml/schema_registry.rb +22 -0
- data/lib/facturx/xml/schema_validator.rb +7 -2
- data/lib/facturx/xml/validator.rb +32 -0
- data/lib/facturx/xml/verifier.rb +16 -11
- data/lib/facturx.rb +41 -4
- data/rbi/facturx.rbi +1755 -0
- 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
|
|
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
|
data/lib/facturx/profile.rb
CHANGED
|
@@ -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
|
data/lib/facturx/profiles.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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'
|