factur-x-builder 0.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 +7 -0
- data/.gitignore +13 -0
- data/.rubocop.yml +29 -0
- data/CHANGELOG.md +25 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +55 -0
- data/LICENSE.txt +21 -0
- data/README.md +169 -0
- data/Rakefile +16 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/factur-x-builder.gemspec +37 -0
- data/lib/factur-x-builder.rb +5 -0
- data/lib/factur_x/address.rb +19 -0
- data/lib/factur_x/allowance_charge.rb +50 -0
- data/lib/factur_x/codes.rb +77 -0
- data/lib/factur_x/contact.rb +18 -0
- data/lib/factur_x/document.rb +400 -0
- data/lib/factur_x/errors.rb +39 -0
- data/lib/factur_x/formatting.rb +84 -0
- data/lib/factur_x/invoice.rb +81 -0
- data/lib/factur_x/line.rb +82 -0
- data/lib/factur_x/note.rb +41 -0
- data/lib/factur_x/party.rb +52 -0
- data/lib/factur_x/payment.rb +46 -0
- data/lib/factur_x/schemas/Factur-X_1.09_EN16931_QualifiedDataType_100.xsd +94 -0
- data/lib/factur_x/schemas/Factur-X_1.09_EN16931_ReusableAggregateBusinessInformationEntity_100.xsd +318 -0
- data/lib/factur_x/schemas/Factur-X_1.09_EN16931_UnqualifiedDataType_100.xsd +84 -0
- data/lib/factur_x/schemas/Factur-X_EN16931.xsd +20 -0
- data/lib/factur_x/tax_breakdown.rb +39 -0
- data/lib/factur_x/totals.rb +35 -0
- data/lib/factur_x/validation/rules.rb +349 -0
- data/lib/factur_x/validation/schema.rb +40 -0
- data/lib/factur_x/version.rb +5 -0
- data/lib/factur_x.rb +49 -0
- metadata +103 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<xs:schema xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
|
3
|
+
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
|
4
|
+
xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100"
|
|
5
|
+
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
|
6
|
+
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
|
|
7
|
+
targetNamespace="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
|
8
|
+
elementFormDefault="qualified">
|
|
9
|
+
<xs:import namespace="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" schemaLocation="Factur-X_1.09_EN16931_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd"/>
|
|
10
|
+
<xs:import namespace="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" schemaLocation="Factur-X_1.09_EN16931_urn_un_unece_uncefact_data_standard_ReusableAggregateBusinessInformationEntity_100.xsd"/>
|
|
11
|
+
<xs:import namespace="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" schemaLocation="Factur-X_1.09_EN16931_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd"/>
|
|
12
|
+
<xs:element name="CrossIndustryInvoice" type="rsm:CrossIndustryInvoiceType"/>
|
|
13
|
+
<xs:complexType name="CrossIndustryInvoiceType">
|
|
14
|
+
<xs:sequence>
|
|
15
|
+
<xs:element name="ExchangedDocumentContext" type="ram:ExchangedDocumentContextType"/>
|
|
16
|
+
<xs:element name="ExchangedDocument" type="ram:ExchangedDocumentType"/>
|
|
17
|
+
<xs:element name="SupplyChainTradeTransaction" type="ram:SupplyChainTradeTransactionType"/>
|
|
18
|
+
</xs:sequence>
|
|
19
|
+
</xs:complexType>
|
|
20
|
+
</xs:schema>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codes"
|
|
4
|
+
require_relative "formatting"
|
|
5
|
+
|
|
6
|
+
module FacturX
|
|
7
|
+
# One VAT category / rate pair of the invoice (BG-23).
|
|
8
|
+
class TaxBreakdown
|
|
9
|
+
attr_reader :category, :rate, :basis_amount, :calculated_amount,
|
|
10
|
+
:exemption_reason, :exemption_reason_code
|
|
11
|
+
|
|
12
|
+
def initialize(basis_amount:, calculated_amount:,
|
|
13
|
+
category: Codes::VatCategory::STANDARD, rate: nil,
|
|
14
|
+
exemption_reason: nil, exemption_reason_code: nil)
|
|
15
|
+
@category = category
|
|
16
|
+
@rate = rate
|
|
17
|
+
@basis_amount = basis_amount
|
|
18
|
+
@calculated_amount = calculated_amount
|
|
19
|
+
@exemption_reason = exemption_reason
|
|
20
|
+
@exemption_reason_code = exemption_reason_code
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def rate_decimal
|
|
24
|
+
Formatting.decimal(rate || 0)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def basis_amount_decimal
|
|
28
|
+
Formatting.decimal(basis_amount)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def calculated_amount_decimal
|
|
32
|
+
Formatting.decimal(calculated_amount)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def tax_key
|
|
36
|
+
[category, rate_decimal]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "formatting"
|
|
4
|
+
|
|
5
|
+
module FacturX
|
|
6
|
+
# The document level totals (BT-106 to BT-115). The gem never derives
|
|
7
|
+
# these; the invoicing domain supplies them and they are checked for
|
|
8
|
+
# coherence against the lines and the VAT breakdowns.
|
|
9
|
+
class Totals
|
|
10
|
+
attr_reader :line_total, :allowance_total, :charge_total, :tax_basis_total,
|
|
11
|
+
:tax_total, :rounding_amount, :grand_total, :prepaid, :due_payable
|
|
12
|
+
|
|
13
|
+
def initialize(line_total:, tax_basis_total:, grand_total:, due_payable:,
|
|
14
|
+
tax_total: nil, allowance_total: nil, charge_total: nil,
|
|
15
|
+
rounding_amount: nil, prepaid: nil)
|
|
16
|
+
@line_total = line_total
|
|
17
|
+
@allowance_total = allowance_total
|
|
18
|
+
@charge_total = charge_total
|
|
19
|
+
@tax_basis_total = tax_basis_total
|
|
20
|
+
@tax_total = tax_total
|
|
21
|
+
@rounding_amount = rounding_amount
|
|
22
|
+
@grand_total = grand_total
|
|
23
|
+
@prepaid = prepaid
|
|
24
|
+
@due_payable = due_payable
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
%i[line_total allowance_total charge_total tax_basis_total tax_total
|
|
28
|
+
rounding_amount grand_total prepaid due_payable].each do |field|
|
|
29
|
+
define_method("#{field}_decimal") do
|
|
30
|
+
value = public_send(field)
|
|
31
|
+
Formatting.decimal(value.nil? ? 0 : value)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "../codes"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
require_relative "../formatting"
|
|
8
|
+
|
|
9
|
+
module FacturX
|
|
10
|
+
module Validation
|
|
11
|
+
# Business rules checked in Ruby so the invoicing domain gets fast,
|
|
12
|
+
# readable errors without shelling out to the official schematron.
|
|
13
|
+
# The rule identifiers refer to EN 16931; the schematron remains the
|
|
14
|
+
# authority and the test suite checks these rules agree with it.
|
|
15
|
+
class Rules
|
|
16
|
+
CURRENCY_PATTERN = /\A[A-Z]{3}\z/.freeze
|
|
17
|
+
COUNTRY_PATTERN = /\A[A-Z]{2}\z/.freeze
|
|
18
|
+
IBAN_PATTERN = /\A[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}\z/.freeze
|
|
19
|
+
SIRET_PATTERN = /\A[0-9]{14}\z/.freeze
|
|
20
|
+
SIREN_PATTERN = /\A[0-9]{9}\z/.freeze
|
|
21
|
+
VAT_NUMBER_PATTERN = /\A[A-Z]{2}[A-Z0-9]{2,13}\z/.freeze
|
|
22
|
+
|
|
23
|
+
ROUNDING_TOLERANCE = BigDecimal("0.01")
|
|
24
|
+
|
|
25
|
+
PROFILES = %i[en16931 fr_ctc].freeze
|
|
26
|
+
|
|
27
|
+
# BR-FR-05: every invoice must carry these three statements.
|
|
28
|
+
MANDATORY_FRENCH_NOTES = {
|
|
29
|
+
Codes::NoteSubject::PAYMENT_TERMS => "recovery costs",
|
|
30
|
+
Codes::NoteSubject::PAYMENT_PENALTIES => "late payment penalties",
|
|
31
|
+
Codes::NoteSubject::DISCOUNT_TERMS => "early payment discount"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
def initialize(invoice, profile: :fr_ctc)
|
|
35
|
+
raise Error, "unknown profile #{profile.inspect}" unless PROFILES.include?(profile)
|
|
36
|
+
|
|
37
|
+
@invoice = invoice
|
|
38
|
+
@profile = profile
|
|
39
|
+
@violations = []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def violations
|
|
43
|
+
@violations = []
|
|
44
|
+
check_document
|
|
45
|
+
check_parties
|
|
46
|
+
check_lines
|
|
47
|
+
check_tax_breakdowns
|
|
48
|
+
check_payment
|
|
49
|
+
check_totals
|
|
50
|
+
check_french_requirements if french?
|
|
51
|
+
@violations
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def valid?
|
|
55
|
+
violations.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate!
|
|
59
|
+
found = violations
|
|
60
|
+
raise ValidationError, found unless found.empty?
|
|
61
|
+
|
|
62
|
+
invoice
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
attr_reader :invoice, :profile
|
|
68
|
+
|
|
69
|
+
def french?
|
|
70
|
+
profile == :fr_ctc
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def add(message)
|
|
74
|
+
@violations << message
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Rules the French e-invoicing reform adds on top of EN 16931.
|
|
78
|
+
def check_french_requirements
|
|
79
|
+
if blank?(invoice.business_process)
|
|
80
|
+
add("an invoicing mode is required by the French e-invoicing reform (BT-23, " \
|
|
81
|
+
"BR-FR-08), expected one of #{Codes::BUSINESS_PROCESSES.join(", ")}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
declared = invoice.notes.map(&:subject_code)
|
|
85
|
+
MANDATORY_FRENCH_NOTES.each do |code, description|
|
|
86
|
+
next if declared.include?(code)
|
|
87
|
+
|
|
88
|
+
add("the mandatory statement about #{description} is missing from the invoice " \
|
|
89
|
+
"notes (BT-22 with subject code #{code}, BR-FR-05) - " \
|
|
90
|
+
"Note.french_legal_mentions provides the three of them")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def check_document
|
|
95
|
+
add("invoice number is required (BT-1)") if blank?(invoice.number)
|
|
96
|
+
add("issue date is required (BT-2)") if invoice.issued_on.nil?
|
|
97
|
+
|
|
98
|
+
unless Codes::DOCUMENT_TYPES.include?(invoice.type_code)
|
|
99
|
+
add("document type #{invoice.type_code.inspect} is not supported (BT-3), " \
|
|
100
|
+
"expected one of #{Codes::DOCUMENT_TYPES.join(", ")}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
unless invoice.currency.to_s.match?(CURRENCY_PATTERN)
|
|
104
|
+
add("currency #{invoice.currency.inspect} is not a 3-letter ISO 4217 code (BT-5)")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
return unless invoice.business_process && !Codes::BUSINESS_PROCESSES.include?(invoice.business_process)
|
|
108
|
+
|
|
109
|
+
add("business process #{invoice.business_process.inspect} is not a Factur-X " \
|
|
110
|
+
"process identifier (BT-23)")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def check_parties
|
|
114
|
+
check_party(invoice.seller, "seller")
|
|
115
|
+
check_party(invoice.buyer, "buyer")
|
|
116
|
+
|
|
117
|
+
if invoice.seller && seller_identifier_missing?
|
|
118
|
+
add("seller must carry a VAT number, a tax registration or a legal registration " \
|
|
119
|
+
"identifier (BR-CO-9 / BR-FR-09)")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
check_french_identifiers(invoice.seller, "seller")
|
|
123
|
+
check_french_identifiers(invoice.buyer, "buyer")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def check_party(party, role)
|
|
127
|
+
if party.nil?
|
|
128
|
+
add("#{role} is required (#{role == "seller" ? "BG-4" : "BG-7"})")
|
|
129
|
+
return
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
add("#{role} name is required") if blank?(party.name)
|
|
133
|
+
|
|
134
|
+
if party.address.nil?
|
|
135
|
+
add("#{role} postal address is required")
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
country = party.address.country_code
|
|
140
|
+
return if country.to_s.match?(COUNTRY_PATTERN)
|
|
141
|
+
|
|
142
|
+
add("#{role} country code #{country.inspect} is not a 2-letter ISO 3166 code")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def seller_identifier_missing?
|
|
146
|
+
seller = invoice.seller
|
|
147
|
+
[seller.vat_number, seller.tax_id, seller.legal_id, seller.global_id].all? { |v| blank?(v) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def check_french_identifiers(party, role)
|
|
151
|
+
return if party.nil?
|
|
152
|
+
|
|
153
|
+
if party.siret && !party.siret.to_s.match?(SIRET_PATTERN)
|
|
154
|
+
add("#{role} SIRET #{party.siret.inspect} must be 14 digits")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if party.siren && !party.siren.to_s.match?(SIREN_PATTERN)
|
|
158
|
+
add("#{role} SIREN #{party.siren.inspect} must be 9 digits")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
return unless party.vat_number && !party.vat_number.to_s.match?(VAT_NUMBER_PATTERN)
|
|
162
|
+
|
|
163
|
+
add("#{role} VAT number #{party.vat_number.inspect} is not a valid EU VAT identifier")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def check_lines
|
|
167
|
+
if invoice.lines.empty?
|
|
168
|
+
add("an invoice must carry at least one line (BR-16)")
|
|
169
|
+
return
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
invoice.lines.each do |line|
|
|
173
|
+
label = "line #{line.number}"
|
|
174
|
+
add("#{label}: name is required (BT-153)") if blank?(line.name)
|
|
175
|
+
add("#{label}: unit code is required (BT-130)") if blank?(line.unit_code)
|
|
176
|
+
check_vat_category(line.vat_category, line.vat_rate, label)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def check_vat_category(category, rate, label)
|
|
181
|
+
unless Codes::VatCategory::ALL.include?(category)
|
|
182
|
+
add("#{label}: VAT category #{category.inspect} is not an EN 16931 category code")
|
|
183
|
+
return
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
decimal_rate = rate.nil? ? nil : Formatting.decimal(rate)
|
|
187
|
+
|
|
188
|
+
if category == Codes::VatCategory::STANDARD
|
|
189
|
+
if decimal_rate.nil? || decimal_rate.zero?
|
|
190
|
+
add("#{label}: standard rated items need a VAT rate above zero (BR-S-05)")
|
|
191
|
+
end
|
|
192
|
+
elsif Codes::VatCategory::ZERO_RATE_ONLY.include?(category) &&
|
|
193
|
+
!decimal_rate.nil? && !decimal_rate.zero?
|
|
194
|
+
add("#{label}: VAT category #{category} requires a zero rate, got #{decimal_rate.to_s("F")}")
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def check_tax_breakdowns
|
|
199
|
+
if invoice.tax_breakdowns.empty?
|
|
200
|
+
add("at least one VAT breakdown is required (BR-CO-18)")
|
|
201
|
+
return
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
invoice.tax_breakdowns.each do |breakdown|
|
|
205
|
+
label = "VAT breakdown #{breakdown.category}/#{breakdown.rate_decimal.to_s("F")}"
|
|
206
|
+
check_vat_category(breakdown.category, breakdown.rate, label)
|
|
207
|
+
check_exemption_reason(breakdown, label)
|
|
208
|
+
check_breakdown_arithmetic(breakdown, label)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
check_breakdown_coverage
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def check_exemption_reason(breakdown, label)
|
|
215
|
+
return unless Codes::VatCategory::REQUIRING_EXEMPTION_REASON.include?(breakdown.category)
|
|
216
|
+
return if !blank?(breakdown.exemption_reason) || !blank?(breakdown.exemption_reason_code)
|
|
217
|
+
|
|
218
|
+
add("#{label}: an exemption reason is required for category #{breakdown.category} " \
|
|
219
|
+
"(BR-E-10 / BR-AE-10 / BR-IC-10 / BR-G-10)")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def check_breakdown_arithmetic(breakdown, label)
|
|
223
|
+
expected = (breakdown.basis_amount_decimal * breakdown.rate_decimal / 100).round(2)
|
|
224
|
+
actual = breakdown.calculated_amount_decimal.round(2)
|
|
225
|
+
return if (expected - actual).abs <= ROUNDING_TOLERANCE
|
|
226
|
+
|
|
227
|
+
add("#{label}: VAT amount #{actual.to_s("F")} does not match " \
|
|
228
|
+
"#{breakdown.basis_amount_decimal.to_s("F")} x #{breakdown.rate_decimal.to_s("F")}% " \
|
|
229
|
+
"= #{expected.to_s("F")} (BR-CO-17)")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def check_breakdown_coverage
|
|
233
|
+
declared = invoice.tax_breakdowns.map(&:tax_key)
|
|
234
|
+
expected_basis = basis_by_tax_key
|
|
235
|
+
|
|
236
|
+
(expected_basis.keys - declared).each do |category, rate|
|
|
237
|
+
add("no VAT breakdown declared for category #{category} at #{rate.to_s("F")}% " \
|
|
238
|
+
"although lines use it (BR-CO-18)")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
invoice.tax_breakdowns.each do |breakdown|
|
|
242
|
+
expected = expected_basis[breakdown.tax_key]
|
|
243
|
+
if expected.nil?
|
|
244
|
+
add("VAT breakdown #{breakdown.category}/#{breakdown.rate_decimal.to_s("F")} " \
|
|
245
|
+
"matches no invoice line")
|
|
246
|
+
next
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
actual = breakdown.basis_amount_decimal.round(2)
|
|
250
|
+
next if actual == expected.round(2)
|
|
251
|
+
|
|
252
|
+
add("VAT breakdown #{breakdown.category}/#{breakdown.rate_decimal.to_s("F")}: taxable " \
|
|
253
|
+
"base #{actual.to_s("F")} does not match the sum of its lines " \
|
|
254
|
+
"#{expected.round(2).to_s("F")} (BR-S-08)")
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def basis_by_tax_key
|
|
259
|
+
totals = Hash.new { |hash, key| hash[key] = BigDecimal(0) }
|
|
260
|
+
|
|
261
|
+
invoice.lines.each { |line| totals[line.tax_key] += line.net_amount_decimal }
|
|
262
|
+
|
|
263
|
+
invoice.allowances.each do |allowance|
|
|
264
|
+
totals[tax_key_of(allowance)] -= allowance.amount_decimal
|
|
265
|
+
end
|
|
266
|
+
invoice.charges.each do |charge|
|
|
267
|
+
totals[tax_key_of(charge)] += charge.amount_decimal
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
totals
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def tax_key_of(item)
|
|
274
|
+
[item.vat_category, Formatting.decimal(item.vat_rate || 0)]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def check_payment
|
|
278
|
+
invoice.payment_means.each do |means|
|
|
279
|
+
if blank?(means.type_code)
|
|
280
|
+
add("payment means type code is required (BT-81)")
|
|
281
|
+
next
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
if Codes::PaymentMeansType::REQUIRING_CREDITOR_ACCOUNT.include?(means.type_code) &&
|
|
285
|
+
!means.creditor_account?
|
|
286
|
+
add("payment means #{means.type_code} (credit transfer) requires a creditor " \
|
|
287
|
+
"account identifier (BR-61)")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
if means.iban && !means.iban.to_s.delete(" ").match?(IBAN_PATTERN)
|
|
291
|
+
add("payment IBAN #{means.iban.inspect} is not a well-formed IBAN")
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def check_totals
|
|
297
|
+
totals = invoice.totals
|
|
298
|
+
if totals.nil?
|
|
299
|
+
add("invoice totals are required (BG-22)")
|
|
300
|
+
return
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
lines_sum = invoice.lines.reduce(BigDecimal(0)) { |sum, line| sum + line.net_amount_decimal }
|
|
304
|
+
compare(totals.line_total_decimal, lines_sum,
|
|
305
|
+
"sum of invoice line net amounts (BT-106)", "the invoice lines", "BR-CO-10")
|
|
306
|
+
|
|
307
|
+
allowances_sum = sum_of(invoice.allowances)
|
|
308
|
+
charges_sum = sum_of(invoice.charges)
|
|
309
|
+
compare(totals.allowance_total_decimal, allowances_sum,
|
|
310
|
+
"allowances total (BT-107)", "the document allowances", "BR-CO-11")
|
|
311
|
+
compare(totals.charge_total_decimal, charges_sum,
|
|
312
|
+
"charges total (BT-108)", "the document charges", "BR-CO-12")
|
|
313
|
+
|
|
314
|
+
compare(totals.tax_basis_total_decimal,
|
|
315
|
+
totals.line_total_decimal - allowances_sum + charges_sum,
|
|
316
|
+
"total without VAT (BT-109)", "lines minus allowances plus charges", "BR-CO-13")
|
|
317
|
+
|
|
318
|
+
vat_sum = invoice.tax_breakdowns.reduce(BigDecimal(0)) do |sum, breakdown|
|
|
319
|
+
sum + breakdown.calculated_amount_decimal
|
|
320
|
+
end
|
|
321
|
+
compare(totals.tax_total_decimal, vat_sum,
|
|
322
|
+
"total VAT amount (BT-110)", "the VAT breakdowns", "BR-CO-14")
|
|
323
|
+
|
|
324
|
+
compare(totals.grand_total_decimal,
|
|
325
|
+
totals.tax_basis_total_decimal + totals.tax_total_decimal,
|
|
326
|
+
"total with VAT (BT-112)", "total without VAT plus VAT", "BR-CO-15")
|
|
327
|
+
|
|
328
|
+
compare(totals.due_payable_decimal,
|
|
329
|
+
totals.grand_total_decimal - totals.prepaid_decimal + totals.rounding_amount_decimal,
|
|
330
|
+
"amount due for payment (BT-115)", "total with VAT minus prepaid", "BR-CO-16")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def sum_of(items)
|
|
334
|
+
items.reduce(BigDecimal(0)) { |sum, item| sum + item.amount_decimal }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def compare(actual, expected, subject, source, rule)
|
|
338
|
+
return if actual.round(2) == expected.round(2)
|
|
339
|
+
|
|
340
|
+
add("#{subject} is #{actual.round(2).to_s("F")} but #{source} add up to " \
|
|
341
|
+
"#{expected.round(2).to_s("F")} (#{rule})")
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def blank?(value)
|
|
345
|
+
value.nil? || value.to_s.strip.empty?
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module FacturX
|
|
8
|
+
module Validation
|
|
9
|
+
# Structural validation against the official Factur-X 1.0.9 EN 16931 XSD.
|
|
10
|
+
module Schema
|
|
11
|
+
ROOT = File.expand_path("../schemas/Factur-X_EN16931.xsd", __dir__)
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def validate(xml)
|
|
15
|
+
document = xml.is_a?(Nokogiri::XML::Document) ? xml : Nokogiri::XML(xml)
|
|
16
|
+
compiled.validate(document).map(&:message)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def valid?(xml)
|
|
20
|
+
validate(xml).empty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate!(xml)
|
|
24
|
+
violations = validate(xml)
|
|
25
|
+
raise SchemaError, violations unless violations.empty?
|
|
26
|
+
|
|
27
|
+
xml
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Compiling the schema costs far more than validating against it, so the
|
|
31
|
+
# imports are resolved once and the result kept for the process lifetime.
|
|
32
|
+
def compiled
|
|
33
|
+
@compiled ||= Nokogiri::XML::Schema.from_document(
|
|
34
|
+
Nokogiri::XML(File.read(ROOT), ROOT)
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/factur_x.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "factur_x/version"
|
|
4
|
+
require_relative "factur_x/errors"
|
|
5
|
+
require_relative "factur_x/codes"
|
|
6
|
+
require_relative "factur_x/formatting"
|
|
7
|
+
require_relative "factur_x/address"
|
|
8
|
+
require_relative "factur_x/contact"
|
|
9
|
+
require_relative "factur_x/party"
|
|
10
|
+
require_relative "factur_x/note"
|
|
11
|
+
require_relative "factur_x/line"
|
|
12
|
+
require_relative "factur_x/tax_breakdown"
|
|
13
|
+
require_relative "factur_x/totals"
|
|
14
|
+
require_relative "factur_x/payment"
|
|
15
|
+
require_relative "factur_x/allowance_charge"
|
|
16
|
+
require_relative "factur_x/invoice"
|
|
17
|
+
require_relative "factur_x/document"
|
|
18
|
+
require_relative "factur_x/validation/schema"
|
|
19
|
+
require_relative "factur_x/validation/rules"
|
|
20
|
+
|
|
21
|
+
# Generates the factur-x.xml attachment of a Factur-X PDF/A-3 invoice,
|
|
22
|
+
# in the EN 16931 profile.
|
|
23
|
+
module FacturX
|
|
24
|
+
class << self
|
|
25
|
+
# @param invoice [Invoice]
|
|
26
|
+
# @param profile [Symbol] :fr_ctc adds the rules of the French e-invoicing
|
|
27
|
+
# reform on top of EN 16931; use :en16931 for invoices issued outside France
|
|
28
|
+
# @param validate [Boolean] check the business rules first
|
|
29
|
+
# @param validate_schema [Boolean] check the result against the Factur-X XSD
|
|
30
|
+
# @return [String] the factur-x.xml document
|
|
31
|
+
def build(invoice, profile: :fr_ctc, validate: true, validate_schema: true)
|
|
32
|
+
Validation::Rules.new(invoice, profile: profile).validate! if validate
|
|
33
|
+
|
|
34
|
+
xml = Document.new(invoice).to_xml
|
|
35
|
+
Validation::Schema.validate!(xml) if validate_schema
|
|
36
|
+
xml
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Collects every business rule violation instead of raising on the first.
|
|
40
|
+
# @return [Array<String>]
|
|
41
|
+
def violations(invoice, profile: :fr_ctc)
|
|
42
|
+
Validation::Rules.new(invoice, profile: profile).violations
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def valid?(invoice, profile: :fr_ctc)
|
|
46
|
+
violations(invoice, profile: profile).empty?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: factur-x-builder
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Hotentic
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-09 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: nokogiri
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.15'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '1.16'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - "~>"
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '1.15'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.16'
|
|
33
|
+
description: Generates the EN 16931 factur-x.xml attachment of a Factur-X invoice
|
|
34
|
+
from invoicing domain objects, with French e-invoicing identifiers (SIRET, SIREN,
|
|
35
|
+
routing address) supported out of the box.
|
|
36
|
+
email:
|
|
37
|
+
- contact@hotentic.com
|
|
38
|
+
executables: []
|
|
39
|
+
extensions: []
|
|
40
|
+
extra_rdoc_files: []
|
|
41
|
+
files:
|
|
42
|
+
- ".gitignore"
|
|
43
|
+
- ".rubocop.yml"
|
|
44
|
+
- CHANGELOG.md
|
|
45
|
+
- CODE_OF_CONDUCT.md
|
|
46
|
+
- Gemfile
|
|
47
|
+
- Gemfile.lock
|
|
48
|
+
- LICENSE.txt
|
|
49
|
+
- README.md
|
|
50
|
+
- Rakefile
|
|
51
|
+
- bin/console
|
|
52
|
+
- bin/setup
|
|
53
|
+
- factur-x-builder.gemspec
|
|
54
|
+
- lib/factur-x-builder.rb
|
|
55
|
+
- lib/factur_x.rb
|
|
56
|
+
- lib/factur_x/address.rb
|
|
57
|
+
- lib/factur_x/allowance_charge.rb
|
|
58
|
+
- lib/factur_x/codes.rb
|
|
59
|
+
- lib/factur_x/contact.rb
|
|
60
|
+
- lib/factur_x/document.rb
|
|
61
|
+
- lib/factur_x/errors.rb
|
|
62
|
+
- lib/factur_x/formatting.rb
|
|
63
|
+
- lib/factur_x/invoice.rb
|
|
64
|
+
- lib/factur_x/line.rb
|
|
65
|
+
- lib/factur_x/note.rb
|
|
66
|
+
- lib/factur_x/party.rb
|
|
67
|
+
- lib/factur_x/payment.rb
|
|
68
|
+
- lib/factur_x/schemas/Factur-X_1.09_EN16931_QualifiedDataType_100.xsd
|
|
69
|
+
- lib/factur_x/schemas/Factur-X_1.09_EN16931_ReusableAggregateBusinessInformationEntity_100.xsd
|
|
70
|
+
- lib/factur_x/schemas/Factur-X_1.09_EN16931_UnqualifiedDataType_100.xsd
|
|
71
|
+
- lib/factur_x/schemas/Factur-X_EN16931.xsd
|
|
72
|
+
- lib/factur_x/tax_breakdown.rb
|
|
73
|
+
- lib/factur_x/totals.rb
|
|
74
|
+
- lib/factur_x/validation/rules.rb
|
|
75
|
+
- lib/factur_x/validation/schema.rb
|
|
76
|
+
- lib/factur_x/version.rb
|
|
77
|
+
homepage: https://hotentic.com
|
|
78
|
+
licenses:
|
|
79
|
+
- MIT
|
|
80
|
+
metadata:
|
|
81
|
+
homepage_uri: https://hotentic.com
|
|
82
|
+
source_code_uri: https://github.com/hotentic/factur-x-builder
|
|
83
|
+
changelog_uri: https://github.com/hotentic/factur-x-builder/blob/main/CHANGELOG.md
|
|
84
|
+
post_install_message:
|
|
85
|
+
rdoc_options: []
|
|
86
|
+
require_paths:
|
|
87
|
+
- lib
|
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: 2.7.0
|
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: '0'
|
|
98
|
+
requirements: []
|
|
99
|
+
rubygems_version: 3.1.6
|
|
100
|
+
signing_key:
|
|
101
|
+
specification_version: 4
|
|
102
|
+
summary: A simple tool to build a factur-x.xml XML file using required input fields.
|
|
103
|
+
test_files: []
|