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,400 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
require_relative "codes"
|
|
6
|
+
require_relative "formatting"
|
|
7
|
+
|
|
8
|
+
module FacturX
|
|
9
|
+
# Serialises an Invoice to Cross Industry Invoice XML.
|
|
10
|
+
#
|
|
11
|
+
# Every complex type in the CII schema is an xs:sequence, so the order in
|
|
12
|
+
# which the emit_* methods write their children is significant and mirrors
|
|
13
|
+
# the XSD. Ordering mistakes surface as schema violations in the test suite.
|
|
14
|
+
class Document
|
|
15
|
+
RSM = "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
|
16
|
+
RAM = "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
|
17
|
+
QDT = "urn:un:unece:uncefact:data:standard:QualifiedDataType:100"
|
|
18
|
+
UDT = "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
|
|
19
|
+
XSI = "http://www.w3.org/2001/XMLSchema-instance"
|
|
20
|
+
|
|
21
|
+
NAMESPACES = {
|
|
22
|
+
"xmlns:rsm" => RSM,
|
|
23
|
+
"xmlns:qdt" => QDT,
|
|
24
|
+
"xmlns:ram" => RAM,
|
|
25
|
+
"xmlns:udt" => UDT,
|
|
26
|
+
"xmlns:xsi" => XSI
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def initialize(invoice)
|
|
30
|
+
@invoice = invoice
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_xml(indent: 2)
|
|
34
|
+
to_document.to_xml(indent: indent, encoding: "UTF-8")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_document
|
|
38
|
+
@to_document ||= build
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :invoice
|
|
44
|
+
|
|
45
|
+
def build
|
|
46
|
+
Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
|
|
47
|
+
xml.CrossIndustryInvoice(NAMESPACES) do
|
|
48
|
+
xml.parent.namespace = xml.parent.namespace_definitions.find { |ns| ns.prefix == "rsm" }
|
|
49
|
+
emit_document_context(xml)
|
|
50
|
+
emit_exchanged_document(xml)
|
|
51
|
+
emit_trade_transaction(xml)
|
|
52
|
+
end
|
|
53
|
+
end.doc
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def emit_document_context(xml)
|
|
57
|
+
xml["rsm"].ExchangedDocumentContext do
|
|
58
|
+
if invoice.business_process
|
|
59
|
+
xml["ram"].BusinessProcessSpecifiedDocumentContextParameter do
|
|
60
|
+
text(xml, :ID, invoice.business_process)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
xml["ram"].GuidelineSpecifiedDocumentContextParameter do
|
|
64
|
+
text(xml, :ID, Codes::EN16931_GUIDELINE)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def emit_exchanged_document(xml)
|
|
70
|
+
xml["rsm"].ExchangedDocument do
|
|
71
|
+
text(xml, :ID, invoice.number)
|
|
72
|
+
text(xml, :TypeCode, invoice.type_code)
|
|
73
|
+
date(xml, :IssueDateTime, invoice.issued_on)
|
|
74
|
+
invoice.notes.each { |note| emit_note(xml, note) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def emit_note(xml, note)
|
|
79
|
+
xml["ram"].IncludedNote do
|
|
80
|
+
text(xml, :Content, note.content)
|
|
81
|
+
text(xml, :SubjectCode, note.subject_code)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def emit_trade_transaction(xml)
|
|
86
|
+
xml["rsm"].SupplyChainTradeTransaction do
|
|
87
|
+
invoice.lines.each { |line| emit_line(xml, line) }
|
|
88
|
+
emit_header_agreement(xml)
|
|
89
|
+
emit_header_delivery(xml)
|
|
90
|
+
emit_header_settlement(xml)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def emit_line(xml, line)
|
|
95
|
+
xml["ram"].IncludedSupplyChainTradeLineItem do
|
|
96
|
+
xml["ram"].AssociatedDocumentLineDocument do
|
|
97
|
+
text(xml, :LineID, line.number)
|
|
98
|
+
xml["ram"].IncludedNote { text(xml, :Content, line.note) } if line.note
|
|
99
|
+
end
|
|
100
|
+
emit_line_product(xml, line)
|
|
101
|
+
emit_line_agreement(xml, line)
|
|
102
|
+
xml["ram"].SpecifiedLineTradeDelivery do
|
|
103
|
+
text(xml, :BilledQuantity, Formatting.quantity(line.quantity), unitCode: line.unit_code)
|
|
104
|
+
end
|
|
105
|
+
emit_line_settlement(xml, line)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def emit_line_product(xml, line)
|
|
110
|
+
xml["ram"].SpecifiedTradeProduct do
|
|
111
|
+
text(xml, :GlobalID, line.global_id, scheme_attribute(line.global_id_scheme))
|
|
112
|
+
text(xml, :SellerAssignedID, line.seller_item_id)
|
|
113
|
+
text(xml, :BuyerAssignedID, line.buyer_item_id)
|
|
114
|
+
text(xml, :Name, line.name)
|
|
115
|
+
text(xml, :Description, line.description)
|
|
116
|
+
xml["ram"].OriginTradeCountry { text(xml, :ID, line.origin_country) } if line.origin_country
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def emit_line_agreement(xml, line)
|
|
121
|
+
xml["ram"].SpecifiedLineTradeAgreement do
|
|
122
|
+
xml["ram"].BuyerOrderReferencedDocument { text(xml, :LineID, line.order_line_id) } if line.order_line_id
|
|
123
|
+
if line.gross_price?
|
|
124
|
+
xml["ram"].GrossPriceProductTradePrice do
|
|
125
|
+
text(xml, :ChargeAmount, Formatting.unit_price(line.gross_unit_price))
|
|
126
|
+
emit_unit_discount(xml, line) if line.unit_discount
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
xml["ram"].NetPriceProductTradePrice do
|
|
130
|
+
text(xml, :ChargeAmount, Formatting.unit_price(line.unit_price))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def emit_unit_discount(xml, line)
|
|
136
|
+
xml["ram"].AppliedTradeAllowanceCharge do
|
|
137
|
+
xml["ram"].ChargeIndicator { boolean(xml, :Indicator, false) }
|
|
138
|
+
text(xml, :ActualAmount, Formatting.unit_price(line.unit_discount))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def emit_line_settlement(xml, line)
|
|
143
|
+
xml["ram"].SpecifiedLineTradeSettlement do
|
|
144
|
+
xml["ram"].ApplicableTradeTax do
|
|
145
|
+
text(xml, :TypeCode, Codes::VAT_TYPE)
|
|
146
|
+
text(xml, :CategoryCode, line.vat_category)
|
|
147
|
+
text(xml, :RateApplicablePercent, Formatting.percentage(line.vat_rate_decimal))
|
|
148
|
+
end
|
|
149
|
+
emit_period(xml, line.period_start, line.period_end)
|
|
150
|
+
xml["ram"].SpecifiedTradeSettlementLineMonetarySummation do
|
|
151
|
+
text(xml, :LineTotalAmount, Formatting.amount(line.net_amount))
|
|
152
|
+
end
|
|
153
|
+
if line.accounting_reference
|
|
154
|
+
xml["ram"].ReceivableSpecifiedTradeAccountingAccount do
|
|
155
|
+
text(xml, :ID, line.accounting_reference)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def emit_header_agreement(xml)
|
|
162
|
+
xml["ram"].ApplicableHeaderTradeAgreement do
|
|
163
|
+
text(xml, :BuyerReference, invoice.buyer_reference)
|
|
164
|
+
emit_party(xml, :SellerTradeParty, invoice.seller)
|
|
165
|
+
emit_party(xml, :BuyerTradeParty, invoice.buyer)
|
|
166
|
+
emit_party(xml, :SellerTaxRepresentativeTradeParty, invoice.tax_representative)
|
|
167
|
+
emit_referenced_document(xml, :SellerOrderReferencedDocument, invoice.sales_order_reference)
|
|
168
|
+
emit_referenced_document(xml, :BuyerOrderReferencedDocument, invoice.purchase_order_reference)
|
|
169
|
+
emit_referenced_document(xml, :ContractReferencedDocument, invoice.contract_reference)
|
|
170
|
+
emit_project(xml)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def emit_project(xml)
|
|
175
|
+
return unless invoice.project_reference
|
|
176
|
+
|
|
177
|
+
xml["ram"].SpecifiedProcuringProject do
|
|
178
|
+
text(xml, :ID, invoice.project_reference)
|
|
179
|
+
text(xml, :Name, invoice.project_name || invoice.project_reference)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def emit_header_delivery(xml)
|
|
184
|
+
xml["ram"].ApplicableHeaderTradeDelivery do
|
|
185
|
+
emit_party(xml, :ShipToTradeParty, invoice.ship_to)
|
|
186
|
+
if invoice.delivered_on
|
|
187
|
+
xml["ram"].ActualDeliverySupplyChainEvent do
|
|
188
|
+
date(xml, :OccurrenceDateTime, invoice.delivered_on)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
emit_referenced_document(xml, :DespatchAdviceReferencedDocument,
|
|
192
|
+
invoice.despatch_advice_reference)
|
|
193
|
+
emit_referenced_document(xml, :ReceivingAdviceReferencedDocument,
|
|
194
|
+
invoice.receiving_advice_reference)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def emit_header_settlement(xml)
|
|
199
|
+
xml["ram"].ApplicableHeaderTradeSettlement do
|
|
200
|
+
text(xml, :CreditorReferenceID, invoice.creditor_reference)
|
|
201
|
+
text(xml, :PaymentReference, invoice.payment_reference)
|
|
202
|
+
text(xml, :InvoiceCurrencyCode, invoice.currency)
|
|
203
|
+
emit_party(xml, :PayeeTradeParty, invoice.payee)
|
|
204
|
+
invoice.payment_means.each { |means| emit_payment_means(xml, means) }
|
|
205
|
+
invoice.tax_breakdowns.each { |breakdown| emit_tax_breakdown(xml, breakdown) }
|
|
206
|
+
emit_period(xml, invoice.billing_period_start, invoice.billing_period_end)
|
|
207
|
+
(invoice.allowances + invoice.charges).each { |item| emit_allowance_charge(xml, item) }
|
|
208
|
+
emit_payment_terms(xml)
|
|
209
|
+
emit_totals(xml)
|
|
210
|
+
invoice.preceding_invoices.each { |reference| emit_preceding_invoice(xml, reference) }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def emit_payment_means(xml, means)
|
|
215
|
+
xml["ram"].SpecifiedTradeSettlementPaymentMeans do
|
|
216
|
+
text(xml, :TypeCode, means.type_code)
|
|
217
|
+
text(xml, :Information, means.information)
|
|
218
|
+
if means.card?
|
|
219
|
+
xml["ram"].ApplicableTradeSettlementFinancialCard do
|
|
220
|
+
text(xml, :ID, means.card_id)
|
|
221
|
+
text(xml, :CardholderName, means.cardholder_name)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
xml["ram"].PayerPartyDebtorFinancialAccount { text(xml, :IBANID, means.debtor_iban) } if means.debtor_iban
|
|
225
|
+
if means.creditor_account?
|
|
226
|
+
xml["ram"].PayeePartyCreditorFinancialAccount do
|
|
227
|
+
text(xml, :IBANID, means.iban)
|
|
228
|
+
text(xml, :AccountName, means.account_name)
|
|
229
|
+
text(xml, :ProprietaryID, means.account_id)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
xml["ram"].PayeeSpecifiedCreditorFinancialInstitution { text(xml, :BICID, means.bic) } if means.bic
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def emit_tax_breakdown(xml, breakdown)
|
|
237
|
+
xml["ram"].ApplicableTradeTax do
|
|
238
|
+
text(xml, :CalculatedAmount, Formatting.amount(breakdown.calculated_amount))
|
|
239
|
+
text(xml, :TypeCode, Codes::VAT_TYPE)
|
|
240
|
+
text(xml, :ExemptionReason, breakdown.exemption_reason)
|
|
241
|
+
text(xml, :BasisAmount, Formatting.amount(breakdown.basis_amount))
|
|
242
|
+
text(xml, :CategoryCode, breakdown.category)
|
|
243
|
+
text(xml, :ExemptionReasonCode, breakdown.exemption_reason_code)
|
|
244
|
+
text(xml, :RateApplicablePercent, Formatting.percentage(breakdown.rate_decimal))
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def emit_allowance_charge(xml, item)
|
|
249
|
+
xml["ram"].SpecifiedTradeAllowanceCharge do
|
|
250
|
+
xml["ram"].ChargeIndicator { boolean(xml, :Indicator, item.charge?) }
|
|
251
|
+
text(xml, :CalculationPercent, Formatting.percentage(item.percentage)) if item.percentage
|
|
252
|
+
text(xml, :BasisAmount, Formatting.amount(item.basis_amount)) if item.basis_amount
|
|
253
|
+
text(xml, :ActualAmount, Formatting.amount(item.amount))
|
|
254
|
+
text(xml, :ReasonCode, item.reason_code)
|
|
255
|
+
text(xml, :Reason, item.reason)
|
|
256
|
+
xml["ram"].CategoryTradeTax do
|
|
257
|
+
text(xml, :TypeCode, Codes::VAT_TYPE)
|
|
258
|
+
text(xml, :CategoryCode, item.vat_category)
|
|
259
|
+
text(xml, :RateApplicablePercent, Formatting.percentage(item.vat_rate || 0))
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def emit_payment_terms(xml)
|
|
265
|
+
terms = invoice.payment_terms
|
|
266
|
+
return if terms.nil? || terms.empty?
|
|
267
|
+
|
|
268
|
+
xml["ram"].SpecifiedTradePaymentTerms do
|
|
269
|
+
text(xml, :Description, terms.description)
|
|
270
|
+
date(xml, :DueDateDateTime, terms.due_on)
|
|
271
|
+
text(xml, :DirectDebitMandateID, terms.mandate_reference)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def emit_totals(xml)
|
|
276
|
+
totals = invoice.totals
|
|
277
|
+
xml["ram"].SpecifiedTradeSettlementHeaderMonetarySummation do
|
|
278
|
+
text(xml, :LineTotalAmount, Formatting.amount(totals.line_total))
|
|
279
|
+
text(xml, :ChargeTotalAmount, optional_amount(totals.charge_total))
|
|
280
|
+
text(xml, :AllowanceTotalAmount, optional_amount(totals.allowance_total))
|
|
281
|
+
text(xml, :TaxBasisTotalAmount, Formatting.amount(totals.tax_basis_total))
|
|
282
|
+
text(xml, :TaxTotalAmount, Formatting.amount(totals.tax_total_decimal),
|
|
283
|
+
currencyID: invoice.currency)
|
|
284
|
+
text(xml, :RoundingAmount, optional_amount(totals.rounding_amount))
|
|
285
|
+
text(xml, :GrandTotalAmount, Formatting.amount(totals.grand_total))
|
|
286
|
+
text(xml, :TotalPrepaidAmount, optional_amount(totals.prepaid))
|
|
287
|
+
text(xml, :DuePayableAmount, Formatting.amount(totals.due_payable))
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def emit_preceding_invoice(xml, reference)
|
|
292
|
+
xml["ram"].InvoiceReferencedDocument do
|
|
293
|
+
text(xml, :IssuerAssignedID, reference.number)
|
|
294
|
+
next unless reference.issued_on
|
|
295
|
+
|
|
296
|
+
xml["ram"].FormattedIssueDateTime do
|
|
297
|
+
xml["qdt"].DateTimeString(Formatting.date(reference.issued_on),
|
|
298
|
+
format: Formatting::CII_DATE_FORMAT_CODE)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def emit_party(xml, element_name, party)
|
|
304
|
+
return if party.nil?
|
|
305
|
+
|
|
306
|
+
xml["ram"].send(element_name) do
|
|
307
|
+
text(xml, :GlobalID, party.global_id, scheme_attribute(party.global_id_scheme))
|
|
308
|
+
text(xml, :Name, party.name)
|
|
309
|
+
emit_legal_organization(xml, party)
|
|
310
|
+
emit_contact(xml, party)
|
|
311
|
+
emit_address(xml, party.address)
|
|
312
|
+
if party.routing_id
|
|
313
|
+
xml["ram"].URIUniversalCommunication do
|
|
314
|
+
text(xml, :URIID, party.routing_id, scheme_attribute(party.routing_id_scheme))
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
party.tax_registrations.each do |value, scheme|
|
|
318
|
+
xml["ram"].SpecifiedTaxRegistration { text(xml, :ID, value, schemeID: scheme) }
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def emit_legal_organization(xml, party)
|
|
324
|
+
return unless party.legal_id || party.trading_name
|
|
325
|
+
|
|
326
|
+
xml["ram"].SpecifiedLegalOrganization do
|
|
327
|
+
text(xml, :ID, party.legal_id, scheme_attribute(party.legal_id_scheme))
|
|
328
|
+
text(xml, :TradingBusinessName, party.trading_name)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def emit_contact(xml, party)
|
|
333
|
+
return unless party.contact?
|
|
334
|
+
|
|
335
|
+
contact = party.contact
|
|
336
|
+
xml["ram"].DefinedTradeContact do
|
|
337
|
+
text(xml, :PersonName, contact.person_name)
|
|
338
|
+
text(xml, :DepartmentName, contact.department_name)
|
|
339
|
+
xml["ram"].TelephoneUniversalCommunication { text(xml, :CompleteNumber, contact.phone) } if contact.phone
|
|
340
|
+
xml["ram"].EmailURIUniversalCommunication { text(xml, :URIID, contact.email) } if contact.email
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def emit_address(xml, address)
|
|
345
|
+
return if address.nil?
|
|
346
|
+
|
|
347
|
+
xml["ram"].PostalTradeAddress do
|
|
348
|
+
text(xml, :PostcodeCode, address.postcode)
|
|
349
|
+
text(xml, :LineOne, address.line_one)
|
|
350
|
+
text(xml, :LineTwo, address.line_two)
|
|
351
|
+
text(xml, :LineThree, address.line_three)
|
|
352
|
+
text(xml, :CityName, address.city)
|
|
353
|
+
text(xml, :CountryID, address.country_code)
|
|
354
|
+
text(xml, :CountrySubDivisionName, address.country_subdivision)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def emit_referenced_document(xml, element_name, reference)
|
|
359
|
+
return if reference.nil?
|
|
360
|
+
|
|
361
|
+
xml["ram"].send(element_name) { text(xml, :IssuerAssignedID, reference) }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def emit_period(xml, starts_on, ends_on)
|
|
365
|
+
return if starts_on.nil? && ends_on.nil?
|
|
366
|
+
|
|
367
|
+
xml["ram"].BillingSpecifiedPeriod do
|
|
368
|
+
date(xml, :StartDateTime, starts_on)
|
|
369
|
+
date(xml, :EndDateTime, ends_on)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def text(xml, name, value, attributes = {})
|
|
374
|
+
return if value.nil?
|
|
375
|
+
|
|
376
|
+
xml["ram"].send(name, value.to_s, attributes)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def boolean(xml, name, value)
|
|
380
|
+
xml["udt"].send(name, value.to_s)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def date(xml, name, value)
|
|
384
|
+
return if value.nil?
|
|
385
|
+
|
|
386
|
+
xml["ram"].send(name) do
|
|
387
|
+
xml["udt"].DateTimeString(Formatting.date(value),
|
|
388
|
+
format: Formatting::CII_DATE_FORMAT_CODE)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def optional_amount(value)
|
|
393
|
+
value.nil? ? nil : Formatting.amount(value)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def scheme_attribute(scheme)
|
|
397
|
+
scheme.nil? ? {} : { schemeID: scheme }
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FacturX
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when the invoice breaks an EN 16931 (or French) business rule.
|
|
7
|
+
class ValidationError < Error
|
|
8
|
+
attr_reader :violations
|
|
9
|
+
|
|
10
|
+
def initialize(violations)
|
|
11
|
+
@violations = Array(violations)
|
|
12
|
+
super(build_message)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def build_message
|
|
18
|
+
return "invoice is not valid" if violations.empty?
|
|
19
|
+
|
|
20
|
+
"invoice is not valid:\n#{bulleted(violations)}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def bulleted(items)
|
|
24
|
+
items.map { |item| " - #{item}" }.join("\n")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised when the generated document does not conform to the Factur-X XSD.
|
|
29
|
+
# This signals a bug in the gem rather than bad input.
|
|
30
|
+
class SchemaError < Error
|
|
31
|
+
attr_reader :violations
|
|
32
|
+
|
|
33
|
+
def initialize(violations)
|
|
34
|
+
@violations = Array(violations)
|
|
35
|
+
details = @violations.map { |violation| " - #{violation}" }.join("\n")
|
|
36
|
+
super("generated XML does not conform to the Factur-X EN 16931 schema:\n#{details}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "bigdecimal/util"
|
|
5
|
+
|
|
6
|
+
module FacturX
|
|
7
|
+
module Formatting
|
|
8
|
+
AMOUNT_SCALE = 2
|
|
9
|
+
PERCENTAGE_SCALE = 2
|
|
10
|
+
UNIT_PRICE_SCALE = 4
|
|
11
|
+
QUANTITY_SCALE = 4
|
|
12
|
+
|
|
13
|
+
CII_DATE_FORMAT = "%Y%m%d"
|
|
14
|
+
CII_DATE_FORMAT_CODE = "102"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def amount(value)
|
|
19
|
+
fixed(value, AMOUNT_SCALE)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def percentage(value)
|
|
23
|
+
fixed(value, PERCENTAGE_SCALE)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def unit_price(value)
|
|
27
|
+
trimmed(value, UNIT_PRICE_SCALE, AMOUNT_SCALE)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def quantity(value)
|
|
31
|
+
trimmed(value, QUANTITY_SCALE, 0)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def date(value)
|
|
35
|
+
to_date(value).strftime(CII_DATE_FORMAT)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def decimal(value)
|
|
39
|
+
case value
|
|
40
|
+
when BigDecimal then value
|
|
41
|
+
when Integer then BigDecimal(value)
|
|
42
|
+
when Float then BigDecimal(value.to_s)
|
|
43
|
+
when String then parse_string(value)
|
|
44
|
+
when nil then raise Error, "expected a number, got nil"
|
|
45
|
+
else coerce(value)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fixed(value, scale)
|
|
50
|
+
integer, fraction = decimal(value).round(scale).to_s("F").split(".")
|
|
51
|
+
"#{integer}.#{fraction.ljust(scale, "0")}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Renders with `scale` decimals at most, dropping trailing zeros but never
|
|
55
|
+
# falling below `minimum_scale` (BT-146 allows 4 decimals, BT-129 allows none).
|
|
56
|
+
def trimmed(value, scale, minimum_scale)
|
|
57
|
+
rounded = decimal(value).round(scale)
|
|
58
|
+
integer, fraction = rounded.to_s("F").split(".")
|
|
59
|
+
fraction = fraction.sub(/0+\z/, "").ljust(minimum_scale, "0")
|
|
60
|
+
fraction.empty? ? integer : "#{integer}.#{fraction}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse_string(value)
|
|
64
|
+
BigDecimal(value)
|
|
65
|
+
rescue ArgumentError
|
|
66
|
+
raise Error, "#{value.inspect} is not a valid number"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def coerce(value)
|
|
70
|
+
return value.to_d if value.respond_to?(:to_d)
|
|
71
|
+
|
|
72
|
+
raise Error, "cannot convert #{value.class} to a number"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_date(value)
|
|
76
|
+
return value if value.respond_to?(:strftime)
|
|
77
|
+
return Date.parse(value) if value.is_a?(String)
|
|
78
|
+
|
|
79
|
+
raise Error, "cannot convert #{value.class} to a date"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private_class_method :parse_string, :coerce, :to_date
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codes"
|
|
4
|
+
|
|
5
|
+
module FacturX
|
|
6
|
+
class Invoice
|
|
7
|
+
attr_reader :number, :type_code, :issued_on, :currency, :business_process,
|
|
8
|
+
:notes, :buyer_reference, :seller, :buyer, :payee,
|
|
9
|
+
:tax_representative, :ship_to, :delivered_on,
|
|
10
|
+
:payment_reference, :creditor_reference, :payment_means, :payment_terms,
|
|
11
|
+
:lines, :allowances, :charges, :tax_breakdowns, :totals,
|
|
12
|
+
:purchase_order_reference, :sales_order_reference, :contract_reference,
|
|
13
|
+
:project_reference, :project_name, :despatch_advice_reference,
|
|
14
|
+
:receiving_advice_reference, :preceding_invoices,
|
|
15
|
+
:billing_period_start, :billing_period_end
|
|
16
|
+
|
|
17
|
+
def initialize(number:, issued_on:, seller:, buyer:, lines:, tax_breakdowns:, totals:,
|
|
18
|
+
type_code: Codes::COMMERCIAL_INVOICE, currency: "EUR",
|
|
19
|
+
business_process: nil, notes: [], buyer_reference: nil,
|
|
20
|
+
payee: nil, tax_representative: nil, ship_to: nil, delivered_on: nil,
|
|
21
|
+
payment_reference: nil, creditor_reference: nil,
|
|
22
|
+
payment_means: [], payment_terms: nil,
|
|
23
|
+
allowances: [], charges: [],
|
|
24
|
+
purchase_order_reference: nil, sales_order_reference: nil,
|
|
25
|
+
contract_reference: nil, project_reference: nil, project_name: nil,
|
|
26
|
+
despatch_advice_reference: nil, receiving_advice_reference: nil,
|
|
27
|
+
preceding_invoices: [],
|
|
28
|
+
billing_period_start: nil, billing_period_end: nil)
|
|
29
|
+
@number = number
|
|
30
|
+
@type_code = type_code
|
|
31
|
+
@issued_on = issued_on
|
|
32
|
+
@currency = currency
|
|
33
|
+
@business_process = business_process
|
|
34
|
+
@notes = Array(notes)
|
|
35
|
+
@buyer_reference = buyer_reference
|
|
36
|
+
@seller = seller
|
|
37
|
+
@buyer = buyer
|
|
38
|
+
@payee = payee
|
|
39
|
+
@tax_representative = tax_representative
|
|
40
|
+
@ship_to = ship_to
|
|
41
|
+
@delivered_on = delivered_on
|
|
42
|
+
@payment_reference = payment_reference
|
|
43
|
+
@creditor_reference = creditor_reference
|
|
44
|
+
@payment_means = Array(payment_means)
|
|
45
|
+
@payment_terms = payment_terms
|
|
46
|
+
@lines = number_lines(Array(lines))
|
|
47
|
+
@allowances = Array(allowances)
|
|
48
|
+
@charges = Array(charges)
|
|
49
|
+
@tax_breakdowns = Array(tax_breakdowns)
|
|
50
|
+
@totals = totals
|
|
51
|
+
@purchase_order_reference = purchase_order_reference
|
|
52
|
+
@sales_order_reference = sales_order_reference
|
|
53
|
+
@contract_reference = contract_reference
|
|
54
|
+
@project_reference = project_reference
|
|
55
|
+
@project_name = project_name
|
|
56
|
+
@despatch_advice_reference = despatch_advice_reference
|
|
57
|
+
@receiving_advice_reference = receiving_advice_reference
|
|
58
|
+
@preceding_invoices = Array(preceding_invoices)
|
|
59
|
+
@billing_period_start = billing_period_start
|
|
60
|
+
@billing_period_end = billing_period_end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def credit_note?
|
|
64
|
+
type_code == Codes::CREDIT_NOTE
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def billing_period?
|
|
68
|
+
!billing_period_start.nil? || !billing_period_end.nil?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_xml(**options)
|
|
72
|
+
FacturX.build(self, **options)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def number_lines(lines)
|
|
78
|
+
lines.each_with_index.map { |line, index| line.with_number(index + 1) }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codes"
|
|
4
|
+
require_relative "formatting"
|
|
5
|
+
|
|
6
|
+
module FacturX
|
|
7
|
+
class Line
|
|
8
|
+
attr_reader :number, :name, :description, :seller_item_id, :buyer_item_id,
|
|
9
|
+
:global_id, :global_id_scheme, :unit_price, :gross_unit_price,
|
|
10
|
+
:unit_discount, :quantity, :unit_code, :vat_category, :vat_rate,
|
|
11
|
+
:net_amount, :note, :origin_country, :period_start, :period_end,
|
|
12
|
+
:order_line_id, :accounting_reference
|
|
13
|
+
|
|
14
|
+
def initialize(name:, unit_price:, quantity:, net_amount:,
|
|
15
|
+
number: nil, description: nil, seller_item_id: nil, buyer_item_id: nil,
|
|
16
|
+
global_id: nil, global_id_scheme: nil, gross_unit_price: nil,
|
|
17
|
+
unit_discount: nil, unit_code: Codes::PIECE_UNIT,
|
|
18
|
+
vat_category: Codes::VatCategory::STANDARD, vat_rate: nil,
|
|
19
|
+
note: nil, origin_country: nil, period_start: nil, period_end: nil,
|
|
20
|
+
order_line_id: nil, accounting_reference: nil)
|
|
21
|
+
@number = number
|
|
22
|
+
@name = name
|
|
23
|
+
@description = description
|
|
24
|
+
@seller_item_id = seller_item_id
|
|
25
|
+
@buyer_item_id = buyer_item_id
|
|
26
|
+
@global_id = global_id
|
|
27
|
+
@global_id_scheme = global_id_scheme
|
|
28
|
+
@unit_price = unit_price
|
|
29
|
+
@gross_unit_price = gross_unit_price
|
|
30
|
+
@unit_discount = unit_discount
|
|
31
|
+
@quantity = quantity
|
|
32
|
+
@unit_code = unit_code
|
|
33
|
+
@vat_category = vat_category
|
|
34
|
+
@vat_rate = vat_rate
|
|
35
|
+
@net_amount = net_amount
|
|
36
|
+
@note = note
|
|
37
|
+
@origin_country = origin_country
|
|
38
|
+
@period_start = period_start
|
|
39
|
+
@period_end = period_end
|
|
40
|
+
@order_line_id = order_line_id
|
|
41
|
+
@accounting_reference = accounting_reference
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def net_amount_decimal
|
|
45
|
+
Formatting.decimal(net_amount)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def vat_rate_decimal
|
|
49
|
+
Formatting.decimal(vat_rate || 0)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Groups lines into VAT breakdowns: BR-S-08 and friends require one
|
|
53
|
+
# breakdown per (category, rate) pair.
|
|
54
|
+
def tax_key
|
|
55
|
+
[vat_category, vat_rate_decimal]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def gross_price?
|
|
59
|
+
!gross_unit_price.nil?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def with_number(value)
|
|
63
|
+
return self if number
|
|
64
|
+
|
|
65
|
+
self.class.new(**to_h, number: value)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_h
|
|
69
|
+
{
|
|
70
|
+
number: number, name: name, description: description,
|
|
71
|
+
seller_item_id: seller_item_id, buyer_item_id: buyer_item_id,
|
|
72
|
+
global_id: global_id, global_id_scheme: global_id_scheme,
|
|
73
|
+
unit_price: unit_price, gross_unit_price: gross_unit_price,
|
|
74
|
+
unit_discount: unit_discount, quantity: quantity, unit_code: unit_code,
|
|
75
|
+
vat_category: vat_category, vat_rate: vat_rate, net_amount: net_amount,
|
|
76
|
+
note: note, origin_country: origin_country,
|
|
77
|
+
period_start: period_start, period_end: period_end,
|
|
78
|
+
order_line_id: order_line_id, accounting_reference: accounting_reference
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|