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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ class Writer
5
+ module Stages
6
+ module TradeSettlementParties
7
+ private
8
+
9
+ def payee(parent)
10
+ within_group('BG-10', document.payee, element: 'ram:PayeeTradeParty', parent:) do |node, party|
11
+ emit_party_identifiers(node, party.identifiers, 'BT-60', 'BT-60-1')
12
+ emit('BT-59', party.name, element: 'ram:Name', parent: node)
13
+ payee_legal_organization(node, party)
14
+ end
15
+ end
16
+
17
+ def payee_legal_organization(parent, party)
18
+ legal = party.legal_registration
19
+ return missing_payee_registration? unless legal
20
+
21
+ container(parent, 'ram:SpecifiedLegalOrganization') do |node|
22
+ emit_compound_identifier(node, legal, value_id: 'BT-61', scheme_id: 'BT-61-1', element: 'ram:ID')
23
+ end
24
+ end
25
+
26
+ def missing_payee_registration?
27
+ observe?('BT-61', nil)
28
+ observe?('BT-61-1', nil, group_present: false)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ class Writer
5
+ module Stages
6
+ module TradeSettlementPayment
7
+ private
8
+
9
+ def payment_means(parent, payment)
10
+ value = payment if payment_means_present?(payment)
11
+ within_group('BG-16', value, element: 'ram:SpecifiedTradeSettlementPaymentMeans', parent:,
12
+ represented_attributes: %i[
13
+ remittance_information payment_card direct_debit credit_transfers
14
+ ]) do |node, item|
15
+ emit_payment_means(parent, node, item)
16
+ end
17
+ end
18
+
19
+ def emit_payment_means(parent, node, payment)
20
+ transfers = payment.credit_transfers
21
+ nodes = [node, *additional_payment_means(parent, transfers)]
22
+ means_code, means_text = payment_descriptions(node, payment)
23
+ duplicate_payment_terms(nodes.drop(1), means_code, means_text)
24
+ payment_card(node, payment.payment_card)
25
+ debtor_account(node, payment.direct_debit)
26
+ credit_transfers(nodes, transfers)
27
+ end
28
+
29
+ def payment_descriptions(parent, payment)
30
+ code = emit('BT-81', payment.means_code, element: 'ram:TypeCode', parent:).first&.text
31
+ text = emit('BT-82', payment.means_text, element: 'ram:Information', parent:).first&.text
32
+ [code, text]
33
+ end
34
+
35
+ def additional_payment_means(parent, transfers)
36
+ Array.new([Array(transfers).size - 1, 0].max) do
37
+ context.element(parent, 'ram:SpecifiedTradeSettlementPaymentMeans')
38
+ end
39
+ end
40
+
41
+ def duplicate_payment_terms(nodes, means_code, means_text)
42
+ nodes.each do |node|
43
+ technical(node, 'ram:TypeCode', means_code)
44
+ technical(node, 'ram:Information', means_text)
45
+ end
46
+ end
47
+
48
+ def payment_means_present?(payment)
49
+ return false unless payment
50
+
51
+ [payment.means_code, payment.means_text, payment.payment_card,
52
+ payment.direct_debit&.debtor_account_identifier, *payment.credit_transfers].any?
53
+ end
54
+
55
+ def payment_card(parent, value)
56
+ within_group('BG-18', value, element: 'ram:ApplicableTradeSettlementFinancialCard', parent:) do |node, card|
57
+ emit('BT-87', card.primary_account_number, element: 'ram:ID', parent: node)
58
+ emit('BT-88', card.holder_name, element: 'ram:CardholderName', parent: node)
59
+ end
60
+ end
61
+
62
+ def debtor_account(parent, value)
63
+ identifier = value&.debtor_account_identifier
64
+ return observe?('BT-91', nil) unless identifier
65
+
66
+ container(parent, 'ram:PayerPartyDebtorFinancialAccount') do |node|
67
+ emit_identifier_value('BT-91', identifier, 'ram:IBANID', node)
68
+ end
69
+ end
70
+
71
+ def credit_transfers(parents, values)
72
+ transfers = Array(values)
73
+ group = Terms.group('BG-17')
74
+ return unless tracker.observe_group?(group, count: transfers.size, path: group.xpath)
75
+
76
+ transfers.zip(parents).each { |item, parent| emit_credit_transfer(parent, item) }
77
+ end
78
+
79
+ def emit_credit_transfer(parent, transfer)
80
+ container(parent, 'ram:PayeePartyCreditorFinancialAccount') do |node|
81
+ emit_identifier_value('BT-84', transfer.account_identifier, 'ram:IBANID', node)
82
+ emit('BT-85', transfer.account_name, element: 'ram:AccountName', parent: node)
83
+ end
84
+ emit_provider(parent, transfer.provider_identifier)
85
+ end
86
+
87
+ def emit_provider(parent, identifier)
88
+ return observe?('BT-86', nil) unless identifier
89
+
90
+ container(parent, 'ram:PayeeSpecifiedCreditorFinancialInstitution') do |node|
91
+ emit_identifier_value('BT-86', identifier, 'ram:BICID', node)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ class Writer
5
+ module Stages
6
+ module TradeSettlementSummary
7
+ private
8
+
9
+ def payment_terms(parent, direct_debit)
10
+ values = [document.payment_terms, document.payment_due_date, direct_debit&.mandate_identifier]
11
+ return missing_payment_terms? unless values.any?
12
+
13
+ container(parent, 'ram:SpecifiedTradePaymentTerms') do |node|
14
+ emit('BT-20', document.payment_terms, element: 'ram:Description', parent: node)
15
+ emit_date(node, 'BT-9', document.payment_due_date, wrapper: 'ram:DueDateDateTime')
16
+ emit_identifier_value('BT-89', direct_debit&.mandate_identifier, 'ram:DirectDebitMandateID', node)
17
+ end
18
+ end
19
+
20
+ def missing_payment_terms?
21
+ observe?('BT-20', nil)
22
+ observe?('BT-9', nil)
23
+ observe?('BT-89', nil)
24
+ end
25
+
26
+ def totals(parent)
27
+ within_group('BG-22', document.totals, element: 'ram:SpecifiedTradeSettlementHeaderMonetarySummation',
28
+ parent:) do |node, item|
29
+ emit_subtotals(node, item)
30
+ emit_tax_totals(node, item)
31
+ emit_balance(node, item)
32
+ end
33
+ end
34
+
35
+ def emit_subtotals(parent, totals)
36
+ emit('BT-106', totals.line_total, element: 'ram:LineTotalAmount', parent:)
37
+ emit('BT-108', totals.charge_total, element: 'ram:ChargeTotalAmount', parent:)
38
+ emit('BT-107', totals.allowance_total, element: 'ram:AllowanceTotalAmount', parent:)
39
+ emit('BT-109', totals.tax_basis_total, element: 'ram:TaxBasisTotalAmount', parent:)
40
+ end
41
+
42
+ def emit_tax_totals(parent, totals)
43
+ emit('BT-110', totals.tax_total, element: 'ram:TaxTotalAmount', parent:,
44
+ attributes: { 'currencyID' => document.currency })
45
+ emit_tax_total_in_tax_currency(parent, totals.tax_total_in_tax_currency)
46
+ end
47
+
48
+ def emit_tax_total_in_tax_currency(parent, value)
49
+ return observe?('BT-111', nil) unless value
50
+ return unrepresentable('BT-111', 'Tax total requires a distinct tax currency') unless distinct_tax_currency?
51
+
52
+ emit('BT-111', value, element: 'ram:TaxTotalAmount', parent:,
53
+ attributes: { 'currencyID' => document.tax_currency })
54
+ end
55
+
56
+ def distinct_tax_currency?
57
+ tax_currency = document.tax_currency
58
+ !tax_currency.to_s.strip.empty? && tax_currency != document.currency
59
+ end
60
+
61
+ def emit_balance(parent, totals)
62
+ emit('BT-114', totals.rounding, element: 'ram:RoundingAmount', parent:)
63
+ emit('BT-112', totals.grand_total, element: 'ram:GrandTotalAmount', parent:)
64
+ emit('BT-113', totals.prepaid, element: 'ram:TotalPrepaidAmount', parent:)
65
+ emit('BT-115', totals.due_payable, element: 'ram:DuePayableAmount', parent:)
66
+ end
67
+
68
+ def preceding_invoices(parent)
69
+ each_group('BG-3', document.preceding_invoices,
70
+ element: 'ram:InvoiceReferencedDocument', parent:) do |node, item|
71
+ emit('BT-25', item.id, element: 'ram:IssuerAssignedID', parent: node)
72
+ emit_date(node, 'BT-26', item.issue_date, wrapper: 'ram:FormattedIssueDateTime',
73
+ value_element: 'qdt:DateTimeString')
74
+ end
75
+ end
76
+
77
+ def accounting_reference(parent)
78
+ value = document.buyer_accounting_reference
79
+ return observe?('BT-19', nil) unless value
80
+
81
+ container(parent, 'ram:ReceivableSpecifiedTradeAccountingAccount') do |node|
82
+ emit('BT-19', value, element: 'ram:ID', parent: node)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ class Writer
5
+ module Stages
6
+ module TradeSettlementTaxes
7
+ ADJUSTMENT_IDS = {
8
+ false => %w[BT-94 BT-93 BT-92 BT-98 BT-97 BT-95 BT-96],
9
+ true => %w[BT-101 BT-100 BT-99 BT-105 BT-104 BT-102 BT-103]
10
+ }.freeze
11
+ ADJUSTMENT_FIELDS = [
12
+ [:percentage, 'ram:CalculationPercent'],
13
+ [:base_amount, 'ram:BasisAmount'],
14
+ [:amount, 'ram:ActualAmount'],
15
+ [:reason_code, 'ram:ReasonCode'],
16
+ [:reason, 'ram:Reason']
17
+ ].freeze
18
+ private_constant :ADJUSTMENT_IDS, :ADJUSTMENT_FIELDS
19
+
20
+ private
21
+
22
+ def taxes(parent)
23
+ first = true
24
+ each_group('BG-23', document.tax_breakdowns, element: 'ram:ApplicableTradeTax', parent:,
25
+ represented_attributes: [:type_code]) do |node, item|
26
+ emit_tax(node, item, include_tax_point: first)
27
+ first = false
28
+ end
29
+ return unless first
30
+
31
+ report_vat_point_attributes
32
+ end
33
+
34
+ def emit_tax(parent, tax, include_tax_point:)
35
+ emit('BT-117', tax.tax_amount, element: 'ram:CalculatedAmount', parent:)
36
+ technical(parent, 'ram:TypeCode', tax.type_code, default: 'VAT')
37
+ emit('BT-120', tax.exemption_reason, element: 'ram:ExemptionReason', parent:)
38
+ emit('BT-116', tax.basis_amount, element: 'ram:BasisAmount', parent:)
39
+ emit('BT-118', tax.category_code, element: 'ram:CategoryCode', parent:)
40
+ emit('BT-121', tax.exemption_reason_code, element: 'ram:ExemptionReasonCode', parent:)
41
+ tax_point_date(parent) if include_tax_point
42
+ tax_due_date_type(parent, tax, check_conflict: include_tax_point)
43
+ emit('BT-119', tax.rate, element: 'ram:RateApplicablePercent', parent:)
44
+ end
45
+
46
+ def tax_due_date_type(parent, tax, check_conflict:)
47
+ document_code = document.vat_point_date_code
48
+ if check_conflict && tax.due_date_type_code && document_code && tax.due_date_type_code != document_code
49
+ report_unrepresentable_attribute('BG-23', model: :document, attribute: :vat_point_date_code)
50
+ end
51
+ emit('BT-8', tax.due_date_type_code || document_code, element: 'ram:DueDateTypeCode', parent:)
52
+ end
53
+
54
+ def tax_point_date(parent)
55
+ emit_date(
56
+ parent, 'BT-7', document.vat_point_date, wrapper: 'ram:TaxPointDate', value_element: 'udt:DateString'
57
+ )
58
+ end
59
+
60
+ def report_vat_point_attributes
61
+ %i[vat_point_date vat_point_date_code].each do |attribute|
62
+ value = document.public_send(attribute)
63
+ report_unrepresentable_attribute('BG-23', model: :document, attribute:) if value
64
+ end
65
+ end
66
+
67
+ def billing_period(parent)
68
+ within_group('BG-14', document.billing_period, element: 'ram:BillingSpecifiedPeriod', parent:) do |node, item|
69
+ emit_date(node, 'BT-73', item.start_date, wrapper: 'ram:StartDateTime')
70
+ emit_date(node, 'BT-74', item.end_date, wrapper: 'ram:EndDateTime')
71
+ end
72
+ end
73
+
74
+ def adjustments(parent, values, group_id, indicator)
75
+ ids = ADJUSTMENT_IDS.fetch(indicator)
76
+ each_group(group_id, values, element: 'ram:SpecifiedTradeAllowanceCharge', parent:,
77
+ represented_attributes: [:indicator]) do |node, item|
78
+ emit_adjustment(node, item, group_id, indicator, ids)
79
+ end
80
+ end
81
+
82
+ def emit_adjustment(parent, adjustment, group_id, indicator, ids)
83
+ adjustment_indicator(parent, group_id, indicator)
84
+ emit_adjustment_values(parent, adjustment, ids)
85
+ adjustment_tax(parent, adjustment.tax, group_id, ids[5], ids[6])
86
+ end
87
+
88
+ def emit_adjustment_values(parent, adjustment, ids)
89
+ ADJUSTMENT_FIELDS.each_with_index do |(attribute, element), index|
90
+ emit(ids[index], adjustment.public_send(attribute), element:, parent:)
91
+ end
92
+ end
93
+
94
+ def adjustment_indicator(parent, group_id, value)
95
+ within_group("#{group_id}-0", value, element: 'ram:ChargeIndicator', parent:) do |node, indicator|
96
+ within_group("#{group_id}-1", indicator, element: 'udt:Indicator', parent: node) do |leaf, raw|
97
+ leaf.content = raw ? 'true' : 'false'
98
+ end
99
+ end
100
+ end
101
+
102
+ def adjustment_tax(parent, tax, group_id, category_id, rate_id)
103
+ return missing_adjustment_tax?(category_id, rate_id) unless tax
104
+
105
+ report_unrepresentable_attributes(group_id, tax, represented_attributes: %i[type_code category_code rate])
106
+
107
+ container(parent, 'ram:CategoryTradeTax') do |node|
108
+ technical(node, 'ram:TypeCode', tax.type_code, default: 'VAT')
109
+ emit(category_id, tax.category_code, element: 'ram:CategoryCode', parent: node)
110
+ emit(rate_id, tax.rate, element: 'ram:RateApplicablePercent', parent: node)
111
+ end
112
+ end
113
+
114
+ def missing_adjustment_tax?(category_id, rate_id)
115
+ observe?(category_id, nil)
116
+ observe?(rate_id, nil)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'trade_settlement/parties'
4
+ require_relative 'trade_settlement/payment'
5
+ require_relative 'trade_settlement/taxes'
6
+ require_relative 'trade_settlement/summary'
7
+
8
+ module Facturx
9
+ class Writer
10
+ module Stages
11
+ class TradeSettlement < Stage
12
+ include TradeSettlementParties
13
+ include TradeSettlementPayment
14
+ include TradeSettlementTaxes
15
+ include TradeSettlementSummary
16
+
17
+ term_ids(*%w[
18
+ BT-90 BT-83 BT-6 BT-5 BT-60 BT-60-1 BT-59 BT-61 BT-61-1 BT-81 BT-82 BT-87 BT-88 BT-91 BT-84
19
+ BT-85 BT-86 BT-117 BT-120 BT-116 BT-118 BT-121 BT-7 BT-8 BT-119 BT-73 BT-74 BT-94 BT-93 BT-92
20
+ BT-98 BT-97 BT-95 BT-96 BT-101 BT-100 BT-99 BT-105 BT-104 BT-102 BT-103 BT-20 BT-9 BT-89 BT-106
21
+ BT-108 BT-107 BT-109 BT-110 BT-111 BT-114 BT-112 BT-113 BT-115 BT-25 BT-26 BT-19
22
+ ])
23
+
24
+ def call
25
+ node = context.element(context.transaction, 'ram:ApplicableHeaderTradeSettlement')
26
+ payment = document.payment
27
+ emit_header(node, payment)
28
+ emit_content(node, payment)
29
+ end
30
+
31
+ private
32
+
33
+ def emit_header(parent, payment)
34
+ direct_debit = payment&.direct_debit
35
+ emit_identifier_value('BT-90', direct_debit&.creditor_identifier, 'ram:CreditorReferenceID', parent)
36
+ emit('BT-83', payment&.remittance_information, element: 'ram:PaymentReference', parent:)
37
+ emit('BT-6', tax_currency_term_value, element: 'ram:TaxCurrencyCode', parent:)
38
+ emit('BT-5', document.currency, element: 'ram:InvoiceCurrencyCode', parent:)
39
+ end
40
+
41
+ def tax_currency_term_value
42
+ return if profile.id == :minimum && document.totals&.tax_total_in_tax_currency
43
+
44
+ document.tax_currency
45
+ end
46
+
47
+ def emit_content(parent, payment)
48
+ payee(parent)
49
+ payment_means(parent, payment)
50
+ taxes(parent)
51
+ billing_period(parent)
52
+ document_adjustments(parent)
53
+ settlement_summary(parent, payment&.direct_debit)
54
+ end
55
+
56
+ def document_adjustments(parent)
57
+ adjustments(parent, document.allowances, 'BG-20', false)
58
+ adjustments(parent, document.charges, 'BG-21', true)
59
+ end
60
+
61
+ def settlement_summary(parent, direct_debit)
62
+ payment_terms(parent, direct_debit)
63
+ totals(parent)
64
+ preceding_invoices(parent)
65
+ accounting_reference(parent)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../error'
4
+ require_relative '../validation'
5
+
6
+ module Facturx
7
+ class Writer
8
+ class Tracker
9
+ attr_reader :profile
10
+
11
+ def initialize(profile:)
12
+ @profile = profile
13
+ @issues = []
14
+ end
15
+
16
+ def check_guideline(guideline_urn, path: nil)
17
+ return if guideline_urn.nil? || guideline_urn == profile.guideline_urn
18
+
19
+ add(
20
+ :profile_mismatch,
21
+ 'Document guideline does not match the selected profile',
22
+ term_id: 'BT-24',
23
+ path:,
24
+ details: { expected: profile.guideline_urn, actual: guideline_urn }
25
+ )
26
+ end
27
+
28
+ def check_document(document, path: nil)
29
+ check_guideline(document.guideline_urn, path:)
30
+ end
31
+
32
+ def observe_group?(group, count:, path: nil)
33
+ definition_observed?(group, count, path)
34
+ end
35
+
36
+ def observe_term?(term, values:, path: nil)
37
+ definition_observed?(term, values.size, path)
38
+ end
39
+
40
+ def invalid_term(term, error:, path: nil)
41
+ raise TypeError, 'error must be a FormattingError' unless error.is_a?(FormattingError)
42
+
43
+ add(
44
+ :invalid_value,
45
+ error.message,
46
+ term_id: term.id,
47
+ group_id: term.group_id,
48
+ path: path || error.details[:path] || term.xpath,
49
+ details: error.details
50
+ )
51
+ end
52
+
53
+ def unrepresentable_attribute(group, model:, attribute:, path: nil)
54
+ add(
55
+ :unrepresentable_attribute,
56
+ 'Model attribute cannot be represented in this group',
57
+ group_id: group.id,
58
+ path: path || group.xpath,
59
+ details: { model:, attribute: }
60
+ )
61
+ end
62
+
63
+ def report
64
+ Validation::Report.new(profile:, issues: @issues)
65
+ end
66
+
67
+ private
68
+
69
+ def definition_observed?(definition, count, path)
70
+ cardinality = definition.cardinalities[profile.id]
71
+ return forbidden?(definition, count, path) unless cardinality
72
+
73
+ missing(definition, path, cardinality) if count.zero? && required?(cardinality)
74
+ invalid_cardinality(definition, count, path, cardinality) if count > 1 && !repeated?(cardinality)
75
+ count.positive?
76
+ end
77
+
78
+ def forbidden?(definition, count, path)
79
+ return false if count.zero?
80
+
81
+ kind = definition_kind(definition)
82
+ add(
83
+ :"forbidden_#{kind}",
84
+ "#{kind.capitalize} is forbidden by the selected profile",
85
+ **definition_context(definition, path),
86
+ details: { profile: profile.id, count: }
87
+ )
88
+ false
89
+ end
90
+
91
+ def missing(definition, path, cardinality)
92
+ kind = definition_kind(definition)
93
+ add(
94
+ :"missing_required_#{kind}",
95
+ "Required #{kind} is missing",
96
+ **definition_context(definition, path),
97
+ details: { profile: profile.id, cardinality: }
98
+ )
99
+ end
100
+
101
+ def invalid_cardinality(definition, count, path, cardinality)
102
+ add(
103
+ :invalid_cardinality,
104
+ 'Value count exceeds the profile cardinality',
105
+ **definition_context(definition, path),
106
+ details: { profile: profile.id, cardinality:, count: }
107
+ )
108
+ end
109
+
110
+ def add(code, message, **context)
111
+ @issues << Validation::Issue.new(code:, message:, layer: :document, **context)
112
+ end
113
+
114
+ def definition_context(definition, path)
115
+ if definition.respond_to?(:group_id)
116
+ { term_id: definition.id, group_id: definition.group_id, path: path || definition.xpath }
117
+ else
118
+ { group_id: definition.id, path: path || definition.xpath }
119
+ end
120
+ end
121
+
122
+ def definition_kind(definition)
123
+ definition.respond_to?(:group_id) ? 'term' : 'group'
124
+ end
125
+
126
+ def required?(cardinality) = cardinality.start_with?('1')
127
+
128
+ def repeated?(cardinality) = cardinality.end_with?('n')
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error'
4
+ require_relative 'terms'
5
+ require_relative 'writer/context'
6
+ require_relative 'writer/stage'
7
+ require_relative 'writer/tracker'
8
+ require_relative 'writer/stages/document_context'
9
+ require_relative 'writer/stages/exchanged_document'
10
+ require_relative 'writer/stages/trade_lines'
11
+ require_relative 'writer/stages/trade_agreement'
12
+ require_relative 'writer/stages/trade_delivery'
13
+ require_relative 'writer/stages/trade_settlement'
14
+ require_relative 'xml/schema_validator'
15
+ require_relative 'xml/report_builder'
16
+
17
+ module Facturx
18
+ class Writer
19
+ STAGES = [
20
+ Stages::DocumentContext,
21
+ Stages::ExchangedDocument,
22
+ Stages::TradeLines,
23
+ Stages::TradeAgreement,
24
+ Stages::TradeDelivery,
25
+ Stages::TradeSettlement
26
+ ].freeze
27
+ MODELED_TERM_IDS = STAGES.flat_map(&:term_ids).freeze
28
+
29
+ unless MODELED_TERM_IDS.sort == Terms.all.map(&:id).sort
30
+ raise 'Factur-X writer stages must cover every modeled term exactly once'
31
+ end
32
+
33
+ def initialize(schema_validator: Xml::SchemaValidator.new)
34
+ @schema_validator = schema_validator
35
+ end
36
+
37
+ def call(document:, profile:)
38
+ context = compile(document:, profile:)
39
+ report = validate_context(context, profile)
40
+ raise_invalid_document(profile, report) if report.invalid?
41
+
42
+ context.to_xml
43
+ end
44
+
45
+ def validate(document:, profile:)
46
+ validate_context(compile(document:, profile:), profile)
47
+ end
48
+
49
+ private
50
+
51
+ def compile(document:, profile:)
52
+ tracker = Tracker.new(profile:)
53
+ tracker.check_document(document, path: Terms.fetch('BT-24').xpath)
54
+ context = Context.new(document:, profile:, tracker:)
55
+ STAGES.each { |stage| stage.new(context).call }
56
+ context
57
+ end
58
+
59
+ def validate_context(context, profile)
60
+ report = context.tracker.report
61
+ return report if report.invalid?
62
+
63
+ @schema_validator.call(document: context.xml, profile:)
64
+ report
65
+ rescue XsdValidationError => e
66
+ Xml::ReportBuilder.xsd(profile, e)
67
+ end
68
+
69
+ def raise_invalid_document(profile, report)
70
+ raise InvalidDocumentError.new(
71
+ 'Factur-X document does not conform to the selected profile',
72
+ profile: profile.id, report:, issues: report.issues
73
+ )
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ module Xml
5
+ module Namespaces
6
+ CII = 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100'
7
+ QUALIFIED = 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100'
8
+ REUSABLE = 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100'
9
+ UNQUALIFIED = 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100'
10
+
11
+ MAP = {
12
+ 'qdt' => QUALIFIED,
13
+ 'ram' => REUSABLE,
14
+ 'rsm' => CII,
15
+ 'udt' => UNQUALIFIED
16
+ }.freeze
17
+ end
18
+ end
19
+ end
@@ -8,7 +8,7 @@ module Facturx
8
8
  class Parser
9
9
  def call(xml:)
10
10
  unless xml.is_a?(String)
11
- raise InvalidXmlError.new('XML must be provided as a byte String', input_class: xml.class.name)
11
+ raise InvalidSourceError.new('XML must be provided as a byte String', input_class: xml.class.name)
12
12
  end
13
13
 
14
14
  Nokogiri::XML::Document.parse(xml) { |config| config.strict.nonet }
@@ -2,14 +2,12 @@
2
2
 
3
3
  require_relative '../profiles'
4
4
  require_relative '../error'
5
+ require_relative 'namespaces'
5
6
 
6
7
  module Facturx
7
8
  module Xml
8
9
  class ProfileDetector
9
- NAMESPACES = {
10
- 'ram' => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100',
11
- 'rsm' => 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100'
12
- }.freeze
10
+ NAMESPACES = Namespaces::MAP
13
11
  GUIDELINE_XPATH = '/rsm:CrossIndustryInvoice/rsm:ExchangedDocumentContext/' \
14
12
  'ram:GuidelineSpecifiedDocumentContextParameter/ram:ID'
15
13