einvoicing 0.2.0 → 0.3.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 +55 -0
- data/README.md +277 -0
- data/config/locales/einvoicing.en.yml +27 -0
- data/config/locales/einvoicing.fr.yml +27 -0
- data/lib/einvoicing/data/srgb.icc +0 -0
- data/lib/einvoicing/formats/cii.rb +218 -0
- data/lib/einvoicing/formats/facturx.rb +195 -0
- data/lib/einvoicing/formats/ubl.rb +226 -0
- data/lib/einvoicing/i18n.rb +22 -0
- data/lib/einvoicing/invoice.rb +99 -0
- data/lib/einvoicing/invoiceable.rb +120 -0
- data/lib/einvoicing/line_item.rb +54 -0
- data/lib/einvoicing/party.rb +29 -0
- data/lib/einvoicing/ppf/client.rb +117 -0
- data/lib/einvoicing/ppf/errors.rb +12 -0
- data/lib/einvoicing/ppf/invoice_adapter.rb +61 -0
- data/lib/einvoicing/ppf/submitter.rb +32 -0
- data/lib/einvoicing/ppf.rb +6 -0
- data/lib/einvoicing/rails/concern.rb +4 -0
- data/lib/einvoicing/rails/engine.rb +21 -0
- data/lib/einvoicing/tax.rb +38 -0
- data/lib/einvoicing/validators/base.rb +52 -0
- data/lib/einvoicing/validators/fr.rb +191 -0
- data/lib/einvoicing/version.rb +1 -1
- data/lib/einvoicing/xml_builder.rb +67 -0
- data/lib/einvoicing.rb +45 -5
- metadata +135 -8
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Einvoicing
|
|
4
|
+
module Formats
|
|
5
|
+
# Embeds a CII XML document into an existing PDF to produce a Factur-X
|
|
6
|
+
# PDF/A-3 file. Requires the `hexapdf` gem.
|
|
7
|
+
#
|
|
8
|
+
# The embedded file is named "factur-x.xml" and tagged as the primary
|
|
9
|
+
# associated file (AFRelationship: Data). XMP metadata is updated to
|
|
10
|
+
# declare PDF/A-3b conformance and the Factur-X extension schema.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# pdf_bytes = File.binread("invoice.pdf")
|
|
14
|
+
# xml = Einvoicing::Formats::CII.generate(invoice)
|
|
15
|
+
# result = Einvoicing::Formats::FacturX.embed(pdf_bytes, xml)
|
|
16
|
+
# File.binwrite("invoice_facturx.pdf", result)
|
|
17
|
+
module FacturX
|
|
18
|
+
FILENAME = "factur-x.xml"
|
|
19
|
+
CONFORMANCE = "EN 16931"
|
|
20
|
+
PROFILE_URN = "urn:factur-x.eu:1p0:en16931"
|
|
21
|
+
FX_NAMESPACE = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#"
|
|
22
|
+
FX_PREFIX = "fx"
|
|
23
|
+
MIME_TYPE = "text/xml"
|
|
24
|
+
DATA_DIR = File.expand_path("../data", __dir__)
|
|
25
|
+
|
|
26
|
+
# Embed CII XML into a PDF binary and return the Factur-X PDF binary.
|
|
27
|
+
#
|
|
28
|
+
# @param pdf_data [String] binary PDF content
|
|
29
|
+
# @param xml_string [String] CII XML string (UTF-8)
|
|
30
|
+
# @param profile [String] Factur-X profile label (default: "EN 16931")
|
|
31
|
+
# @return [String] binary Factur-X PDF/A-3 content
|
|
32
|
+
def self.embed(pdf_data, xml_string, profile: CONFORMANCE)
|
|
33
|
+
unless pdf_data.to_s.b.start_with?("%PDF-")
|
|
34
|
+
raise ArgumentError, "pdf_data does not appear to be a valid PDF (missing %PDF- magic bytes)"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
require "hexapdf"
|
|
38
|
+
|
|
39
|
+
io = StringIO.new(pdf_data.dup.force_encoding("BINARY"))
|
|
40
|
+
doc = HexaPDF::Document.new(io: io)
|
|
41
|
+
|
|
42
|
+
xml_bytes = xml_string.encode("UTF-8").b
|
|
43
|
+
|
|
44
|
+
# 1. Embed the XML as an embedded file stream.
|
|
45
|
+
ef_stream = doc.add({
|
|
46
|
+
Type: :EmbeddedFile,
|
|
47
|
+
Subtype: "text/xml",
|
|
48
|
+
Params: { Size: xml_bytes.bytesize, CheckSum: md5(xml_bytes) }
|
|
49
|
+
})
|
|
50
|
+
ef_stream.set_filter(:FlateDecode)
|
|
51
|
+
ef_stream.stream = xml_bytes
|
|
52
|
+
|
|
53
|
+
filespec = doc.add({
|
|
54
|
+
Type: :Filespec,
|
|
55
|
+
F: FILENAME,
|
|
56
|
+
UF: FILENAME,
|
|
57
|
+
AFRelationship: :Data,
|
|
58
|
+
Desc: "Factur-X invoice",
|
|
59
|
+
EF: { F: ef_stream, UF: ef_stream }
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
# 2. Register in the EmbeddedFiles name tree.
|
|
63
|
+
doc.catalog[:Names] ||= doc.add({})
|
|
64
|
+
names_dict = doc.catalog[:Names]
|
|
65
|
+
names_dict[:EmbeddedFiles] ||= doc.add({ Names: [] })
|
|
66
|
+
names_dict[:EmbeddedFiles][:Names] << FILENAME << filespec
|
|
67
|
+
|
|
68
|
+
# 3. Set AF array on the catalog.
|
|
69
|
+
doc.catalog[:AF] = [filespec]
|
|
70
|
+
|
|
71
|
+
# 4. Add OutputIntent (required for PDF/A-3 conformance).
|
|
72
|
+
add_output_intent(doc)
|
|
73
|
+
|
|
74
|
+
# 5. Update XMP metadata.
|
|
75
|
+
update_xmp(doc, profile)
|
|
76
|
+
|
|
77
|
+
# 6. Write back to binary string.
|
|
78
|
+
out = StringIO.new("".b)
|
|
79
|
+
doc.write(out)
|
|
80
|
+
result = out.string
|
|
81
|
+
|
|
82
|
+
# PDF/A-3 requires %PDF-1.x header (PDF 2.0 is not permitted).
|
|
83
|
+
# HexaPDF preserves the source version in the written header, so
|
|
84
|
+
# patch it here if the source was PDF 2.0.
|
|
85
|
+
result.sub!(/\A%PDF-2\.\d/, "%PDF-1.7")
|
|
86
|
+
result
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private_class_method def self.update_xmp(doc, profile)
|
|
90
|
+
raw_xmp = build_xmp(profile)
|
|
91
|
+
|
|
92
|
+
# HexaPDF stores XMP in the document's metadata stream.
|
|
93
|
+
meta = doc.catalog[:Metadata]
|
|
94
|
+
if meta
|
|
95
|
+
meta.stream = raw_xmp
|
|
96
|
+
else
|
|
97
|
+
meta = doc.add({ Type: :Metadata, Subtype: :XML })
|
|
98
|
+
meta.stream = raw_xmp
|
|
99
|
+
doc.catalog[:Metadata] = meta
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# rubocop:disable Metrics/MethodLength
|
|
104
|
+
private_class_method def self.build_xmp(profile)
|
|
105
|
+
<<~XMP
|
|
106
|
+
<?xpacket begin="\xEF\xBB\xBF" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
|
107
|
+
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
|
108
|
+
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
|
109
|
+
<rdf:Description rdf:about=""
|
|
110
|
+
xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/"
|
|
111
|
+
xmlns:#{FX_PREFIX}="#{FX_NAMESPACE}">
|
|
112
|
+
<pdfaid:part>3</pdfaid:part>
|
|
113
|
+
<pdfaid:conformance>B</pdfaid:conformance>
|
|
114
|
+
<#{FX_PREFIX}:DocumentType>INVOICE</#{FX_PREFIX}:DocumentType>
|
|
115
|
+
<#{FX_PREFIX}:DocumentFileName>#{FILENAME}</#{FX_PREFIX}:DocumentFileName>
|
|
116
|
+
<#{FX_PREFIX}:Version>1.0</#{FX_PREFIX}:Version>
|
|
117
|
+
<#{FX_PREFIX}:ConformanceLevel>#{profile}</#{FX_PREFIX}:ConformanceLevel>
|
|
118
|
+
</rdf:Description>
|
|
119
|
+
<rdf:Description rdf:about=""
|
|
120
|
+
xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/"
|
|
121
|
+
xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"
|
|
122
|
+
xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
|
|
123
|
+
<pdfaExtension:schemas>
|
|
124
|
+
<rdf:Bag>
|
|
125
|
+
<rdf:li rdf:parseType="Resource">
|
|
126
|
+
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
|
|
127
|
+
<pdfaSchema:namespaceURI>#{FX_NAMESPACE}</pdfaSchema:namespaceURI>
|
|
128
|
+
<pdfaSchema:prefix>#{FX_PREFIX}</pdfaSchema:prefix>
|
|
129
|
+
<pdfaSchema:property>
|
|
130
|
+
<rdf:Seq>
|
|
131
|
+
<rdf:li rdf:parseType="Resource">
|
|
132
|
+
<pdfaProperty:name>DocumentFileName</pdfaProperty:name>
|
|
133
|
+
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
|
134
|
+
<pdfaProperty:category>external</pdfaProperty:category>
|
|
135
|
+
<pdfaProperty:description>The name of the embedded XML invoice file</pdfaProperty:description>
|
|
136
|
+
</rdf:li>
|
|
137
|
+
<rdf:li rdf:parseType="Resource">
|
|
138
|
+
<pdfaProperty:name>DocumentType</pdfaProperty:name>
|
|
139
|
+
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
|
140
|
+
<pdfaProperty:category>external</pdfaProperty:category>
|
|
141
|
+
<pdfaProperty:description>The type of the hybrid document (INVOICE)</pdfaProperty:description>
|
|
142
|
+
</rdf:li>
|
|
143
|
+
<rdf:li rdf:parseType="Resource">
|
|
144
|
+
<pdfaProperty:name>Version</pdfaProperty:name>
|
|
145
|
+
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
|
146
|
+
<pdfaProperty:category>external</pdfaProperty:category>
|
|
147
|
+
<pdfaProperty:description>The version of the Factur-X specification</pdfaProperty:description>
|
|
148
|
+
</rdf:li>
|
|
149
|
+
<rdf:li rdf:parseType="Resource">
|
|
150
|
+
<pdfaProperty:name>ConformanceLevel</pdfaProperty:name>
|
|
151
|
+
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
|
152
|
+
<pdfaProperty:category>external</pdfaProperty:category>
|
|
153
|
+
<pdfaProperty:description>The conformance level of the embedded XML invoice</pdfaProperty:description>
|
|
154
|
+
</rdf:li>
|
|
155
|
+
</rdf:Seq>
|
|
156
|
+
</pdfaSchema:property>
|
|
157
|
+
</rdf:li>
|
|
158
|
+
</rdf:Bag>
|
|
159
|
+
</pdfaExtension:schemas>
|
|
160
|
+
</rdf:Description>
|
|
161
|
+
</rdf:RDF>
|
|
162
|
+
</x:xmpmeta>
|
|
163
|
+
<?xpacket end="w"?>
|
|
164
|
+
XMP
|
|
165
|
+
end
|
|
166
|
+
# rubocop:enable Metrics/MethodLength
|
|
167
|
+
|
|
168
|
+
private_class_method def self.add_output_intent(doc)
|
|
169
|
+
icc_path = File.join(DATA_DIR, "srgb.icc")
|
|
170
|
+
icc_data = File.binread(icc_path)
|
|
171
|
+
|
|
172
|
+
icc_stream = doc.add(
|
|
173
|
+
{ Type: :ICCBased, N: 3, Alternate: :DeviceRGB },
|
|
174
|
+
stream: icc_data
|
|
175
|
+
)
|
|
176
|
+
icc_stream.set_filter(:FlateDecode)
|
|
177
|
+
|
|
178
|
+
output_intent = doc.add({
|
|
179
|
+
Type: :OutputIntent,
|
|
180
|
+
S: :GTS_PDFA1,
|
|
181
|
+
OutputConditionIdentifier: "sRGB IEC61966-2.1",
|
|
182
|
+
Info: "sRGB IEC61966-2.1",
|
|
183
|
+
DestOutputProfile: icc_stream
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
doc.catalog[:OutputIntents] = [output_intent]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private_class_method def self.md5(bytes)
|
|
190
|
+
require "digest"
|
|
191
|
+
Digest::MD5.digest(bytes)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Einvoicing
|
|
4
|
+
module Formats
|
|
5
|
+
# Generates UBL 2.1 XML compliant with EN 16931 / Peppol BIS Billing 3.0.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# xml = Einvoicing::Formats::UBL.generate(invoice)
|
|
9
|
+
# File.write("invoice.xml", xml)
|
|
10
|
+
module UBL
|
|
11
|
+
CUSTOMIZATION_ID = "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0"
|
|
12
|
+
PROFILE_ID = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"
|
|
13
|
+
|
|
14
|
+
UBL_NS = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
|
15
|
+
UBL_CREDIT_NOTE_NS = "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2"
|
|
16
|
+
CAC_NS = "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
|
|
17
|
+
CBC_NS = "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
|
|
18
|
+
|
|
19
|
+
def self.generate(invoice)
|
|
20
|
+
b = XMLBuilder.new
|
|
21
|
+
credit_note = invoice.document_type == :credit_note
|
|
22
|
+
root_ns = credit_note ? UBL_CREDIT_NOTE_NS : UBL_NS
|
|
23
|
+
root_tag = credit_note ? "CreditNote" : "Invoice"
|
|
24
|
+
b.tag(
|
|
25
|
+
root_tag,
|
|
26
|
+
"xmlns" => root_ns,
|
|
27
|
+
"xmlns:cac" => CAC_NS,
|
|
28
|
+
"xmlns:cbc" => CBC_NS
|
|
29
|
+
) do
|
|
30
|
+
header(b, invoice)
|
|
31
|
+
supplier_party(b, invoice.seller)
|
|
32
|
+
customer_party(b, invoice.buyer)
|
|
33
|
+
billing_reference(b, invoice) if credit_note && invoice.original_invoice_number
|
|
34
|
+
payment_means(b, invoice) if invoice.payment_means_code
|
|
35
|
+
tax_total(b, invoice)
|
|
36
|
+
monetary_total(b, invoice)
|
|
37
|
+
invoice.lines.each_with_index do |line, idx|
|
|
38
|
+
invoice_line(b, line, idx + 1, invoice.currency)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
b.to_xml
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# -- Private helpers ---------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def self.header(b, invoice)
|
|
47
|
+
b.text("cbc:CustomizationID", CUSTOMIZATION_ID)
|
|
48
|
+
b.text("cbc:ProfileID", PROFILE_ID)
|
|
49
|
+
b.text("cbc:ID", invoice.invoice_number)
|
|
50
|
+
b.text("cbc:IssueDate", format_date(invoice.issue_date))
|
|
51
|
+
b.text("cbc:DueDate", format_date(invoice.due_date)) if invoice.due_date
|
|
52
|
+
b.text("cbc:InvoiceTypeCode", invoice.document_type == :credit_note ? "381" : "380")
|
|
53
|
+
b.text("cbc:Note", invoice.note) if invoice.note
|
|
54
|
+
b.text("cbc:DocumentCurrencyCode", invoice.currency)
|
|
55
|
+
b.text("cbc:TaxCurrencyCode", invoice.tax_currency) if invoice.tax_currency
|
|
56
|
+
b.text("cbc:BuyerReference", invoice.payment_reference || invoice.invoice_number)
|
|
57
|
+
end
|
|
58
|
+
private_class_method :header
|
|
59
|
+
|
|
60
|
+
def self.supplier_party(b, party)
|
|
61
|
+
b.tag("cac:AccountingSupplierParty") do
|
|
62
|
+
b.tag("cac:Party") do
|
|
63
|
+
party_name(b, party)
|
|
64
|
+
postal_address(b, party)
|
|
65
|
+
tax_scheme(b, party)
|
|
66
|
+
legal_entity(b, party)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
private_class_method :supplier_party
|
|
71
|
+
|
|
72
|
+
def self.customer_party(b, party)
|
|
73
|
+
b.tag("cac:AccountingCustomerParty") do
|
|
74
|
+
b.tag("cac:Party") do
|
|
75
|
+
party_name(b, party)
|
|
76
|
+
postal_address(b, party)
|
|
77
|
+
tax_scheme(b, party)
|
|
78
|
+
legal_entity(b, party)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
private_class_method :customer_party
|
|
83
|
+
|
|
84
|
+
def self.party_name(b, party)
|
|
85
|
+
b.tag("cac:PartyName") do
|
|
86
|
+
b.text("cbc:Name", party.name)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
private_class_method :party_name
|
|
90
|
+
|
|
91
|
+
def self.postal_address(b, party)
|
|
92
|
+
b.tag("cac:PostalAddress") do
|
|
93
|
+
b.text("cbc:StreetName", party.street)
|
|
94
|
+
b.text("cbc:CityName", party.city)
|
|
95
|
+
b.text("cbc:PostalZone", party.postal_code)
|
|
96
|
+
b.tag("cac:Country") do
|
|
97
|
+
b.text("cbc:IdentificationCode", party.country_code || "FR")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
private_class_method :postal_address
|
|
102
|
+
|
|
103
|
+
def self.tax_scheme(b, party)
|
|
104
|
+
return unless party.vat_number
|
|
105
|
+
|
|
106
|
+
b.tag("cac:PartyTaxScheme") do
|
|
107
|
+
b.text("cbc:CompanyID", party.vat_number)
|
|
108
|
+
b.tag("cac:TaxScheme") { b.text("cbc:ID", "VAT") }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
private_class_method :tax_scheme
|
|
112
|
+
|
|
113
|
+
def self.legal_entity(b, party)
|
|
114
|
+
b.tag("cac:PartyLegalEntity") do
|
|
115
|
+
b.text("cbc:RegistrationName", party.name)
|
|
116
|
+
b.text("cbc:CompanyID", party.siren_number, "schemeID" => "0002") if party.siren_number
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
private_class_method :legal_entity
|
|
120
|
+
|
|
121
|
+
def self.tax_total(b, invoice)
|
|
122
|
+
b.tag("cac:TaxTotal") do
|
|
123
|
+
b.text("cbc:TaxAmount", format_amount(invoice.tax_total),
|
|
124
|
+
"currencyID" => invoice.currency)
|
|
125
|
+
invoice.tax_breakdown.each do |tax|
|
|
126
|
+
b.tag("cac:TaxSubtotal") do
|
|
127
|
+
b.text("cbc:TaxableAmount", format_amount(tax.taxable_amount),
|
|
128
|
+
"currencyID" => invoice.currency)
|
|
129
|
+
b.text("cbc:TaxAmount", format_amount(tax.tax_amount),
|
|
130
|
+
"currencyID" => invoice.currency)
|
|
131
|
+
b.tag("cac:TaxCategory") do
|
|
132
|
+
b.text("cbc:ID", tax.category_code)
|
|
133
|
+
b.text("cbc:Percent", format_amount(tax.rate_percent))
|
|
134
|
+
b.tag("cac:TaxScheme") { b.text("cbc:ID", "VAT") }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
private_class_method :tax_total
|
|
141
|
+
|
|
142
|
+
def self.monetary_total(b, invoice)
|
|
143
|
+
b.tag("cac:LegalMonetaryTotal") do
|
|
144
|
+
b.text("cbc:LineExtensionAmount", format_amount(invoice.net_total),
|
|
145
|
+
"currencyID" => invoice.currency)
|
|
146
|
+
b.text("cbc:TaxExclusiveAmount", format_amount(invoice.net_total),
|
|
147
|
+
"currencyID" => invoice.currency)
|
|
148
|
+
b.text("cbc:TaxInclusiveAmount", format_amount(invoice.gross_total),
|
|
149
|
+
"currencyID" => invoice.currency)
|
|
150
|
+
b.text("cbc:PayableAmount", format_amount(invoice.due_amount),
|
|
151
|
+
"currencyID" => invoice.currency)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
private_class_method :monetary_total
|
|
155
|
+
|
|
156
|
+
def self.invoice_line(b, line, index, currency)
|
|
157
|
+
b.tag("cac:InvoiceLine") do
|
|
158
|
+
b.text("cbc:ID", index.to_s)
|
|
159
|
+
b.text("cbc:InvoicedQuantity", format_quantity(line.quantity), "unitCode" => line.unit)
|
|
160
|
+
b.text("cbc:LineExtensionAmount", format_amount(line.net_amount),
|
|
161
|
+
"currencyID" => currency)
|
|
162
|
+
b.tag("cac:Item") do
|
|
163
|
+
b.text("cbc:Description", line.description)
|
|
164
|
+
b.text("cbc:Name", line.description)
|
|
165
|
+
b.tag("cac:ClassifiedTaxCategory") do
|
|
166
|
+
b.text("cbc:ID", line.tax_category_code)
|
|
167
|
+
b.text("cbc:Percent", format_amount(line.vat_rate_percent))
|
|
168
|
+
b.tag("cac:TaxScheme") { b.text("cbc:ID", "VAT") }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
b.tag("cac:Price") do
|
|
172
|
+
b.text("cbc:PriceAmount", format_amount(line.unit_price),
|
|
173
|
+
"currencyID" => currency)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
private_class_method :invoice_line
|
|
178
|
+
|
|
179
|
+
def self.billing_reference(b, invoice)
|
|
180
|
+
b.tag("cac:BillingReference") do
|
|
181
|
+
b.tag("cac:InvoiceDocumentReference") do
|
|
182
|
+
b.text("cbc:ID", invoice.original_invoice_number)
|
|
183
|
+
if invoice.original_invoice_date
|
|
184
|
+
b.text("cbc:IssueDate", format_date(invoice.original_invoice_date))
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
private_class_method :billing_reference
|
|
190
|
+
|
|
191
|
+
def self.payment_means(b, invoice)
|
|
192
|
+
b.tag("cac:PaymentMeans") do
|
|
193
|
+
b.text("cbc:PaymentMeansCode", invoice.payment_means_code.to_s)
|
|
194
|
+
if invoice.iban
|
|
195
|
+
b.tag("cac:PayeeFinancialAccount") do
|
|
196
|
+
b.text("cbc:ID", invoice.iban)
|
|
197
|
+
if invoice.bic
|
|
198
|
+
b.tag("cac:FinancialInstitutionBranch") do
|
|
199
|
+
b.text("cbc:ID", invoice.bic)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
private_class_method :payment_means
|
|
207
|
+
|
|
208
|
+
def self.format_date(date)
|
|
209
|
+
d = date.is_a?(Date) ? date : Date.parse(date.to_s)
|
|
210
|
+
d.strftime("%Y-%m-%d")
|
|
211
|
+
end
|
|
212
|
+
private_class_method :format_date
|
|
213
|
+
|
|
214
|
+
def self.format_amount(value)
|
|
215
|
+
format("%.2f", value)
|
|
216
|
+
end
|
|
217
|
+
private_class_method :format_amount
|
|
218
|
+
|
|
219
|
+
def self.format_quantity(value)
|
|
220
|
+
v = value.to_f
|
|
221
|
+
v % 1 == 0 ? v.to_i.to_s : format("%.4f", v)
|
|
222
|
+
end
|
|
223
|
+
private_class_method :format_quantity
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Einvoicing
|
|
4
|
+
# Thin wrapper around ::I18n with graceful fallback for standalone use.
|
|
5
|
+
# When used outside Rails, ::I18n may not be available; in that case the
|
|
6
|
+
# dotted key string is returned as-is.
|
|
7
|
+
module I18n
|
|
8
|
+
DEFAULT_LOCALE = :en
|
|
9
|
+
|
|
10
|
+
def self.t(key, **options)
|
|
11
|
+
return key.to_s unless defined?(::I18n)
|
|
12
|
+
|
|
13
|
+
locale = options.delete(:locale) { ::I18n.locale rescue DEFAULT_LOCALE }
|
|
14
|
+
::I18n.t("einvoicing.#{key}", locale: locale, **options)
|
|
15
|
+
rescue ::I18n::MissingTranslationData
|
|
16
|
+
# Fallback to English if translation missing in current locale
|
|
17
|
+
::I18n.t("einvoicing.#{key}", locale: DEFAULT_LOCALE, **options)
|
|
18
|
+
rescue StandardError
|
|
19
|
+
key.to_s
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "bigdecimal/util"
|
|
5
|
+
|
|
6
|
+
module Einvoicing
|
|
7
|
+
# Core invoice model. All monetary values are in the invoice currency.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# seller = Einvoicing::Party.new(name: "Acme SAS", siren: "123456789", vat_number: "FR12123456789")
|
|
11
|
+
# buyer = Einvoicing::Party.new(name: "Client SA", siren: "987654321")
|
|
12
|
+
# line = Einvoicing::LineItem.new(description: "Consulting", quantity: 1, unit_price: 1000.00)
|
|
13
|
+
#
|
|
14
|
+
# invoice = Einvoicing::Invoice.new(
|
|
15
|
+
# invoice_number: "INV-2024-001",
|
|
16
|
+
# issue_date: Date.today,
|
|
17
|
+
# seller: seller,
|
|
18
|
+
# buyer: buyer,
|
|
19
|
+
# lines: [line]
|
|
20
|
+
# )
|
|
21
|
+
Invoice = Data.define(
|
|
22
|
+
:invoice_number,
|
|
23
|
+
:issue_date,
|
|
24
|
+
:due_date,
|
|
25
|
+
:currency,
|
|
26
|
+
:tax_currency,
|
|
27
|
+
:seller,
|
|
28
|
+
:buyer,
|
|
29
|
+
:lines,
|
|
30
|
+
:tax_breakdown,
|
|
31
|
+
:payment_reference,
|
|
32
|
+
:note,
|
|
33
|
+
:payment_means_code,
|
|
34
|
+
:iban,
|
|
35
|
+
:bic,
|
|
36
|
+
:document_type,
|
|
37
|
+
:original_invoice_number,
|
|
38
|
+
:original_invoice_date
|
|
39
|
+
) do
|
|
40
|
+
def initialize(invoice_number:, issue_date:, seller:, buyer:, lines:,
|
|
41
|
+
due_date: nil, currency: "EUR", tax_currency: nil, tax_breakdown: nil,
|
|
42
|
+
payment_reference: nil, note: nil,
|
|
43
|
+
payment_means_code: nil, iban: nil, bic: nil,
|
|
44
|
+
document_type: :invoice, original_invoice_number: nil, original_invoice_date: nil)
|
|
45
|
+
computed_breakdown = tax_breakdown || compute_tax_breakdown(lines)
|
|
46
|
+
super(
|
|
47
|
+
invoice_number: invoice_number,
|
|
48
|
+
issue_date: issue_date,
|
|
49
|
+
due_date: due_date,
|
|
50
|
+
currency: currency,
|
|
51
|
+
tax_currency: tax_currency,
|
|
52
|
+
seller: seller,
|
|
53
|
+
buyer: buyer,
|
|
54
|
+
lines: lines,
|
|
55
|
+
tax_breakdown: computed_breakdown,
|
|
56
|
+
payment_reference: payment_reference,
|
|
57
|
+
note: note,
|
|
58
|
+
payment_means_code: payment_means_code,
|
|
59
|
+
iban: iban,
|
|
60
|
+
bic: bic,
|
|
61
|
+
document_type: document_type,
|
|
62
|
+
original_invoice_number: original_invoice_number,
|
|
63
|
+
original_invoice_date: original_invoice_date
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Sum of all line net amounts (excl. VAT).
|
|
68
|
+
def net_total
|
|
69
|
+
lines.sum(BigDecimal("0"), &:net_amount).round(2, :half_up)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Total VAT across all lines.
|
|
73
|
+
def tax_total
|
|
74
|
+
tax_breakdown.sum(BigDecimal("0"), &:tax_amount).round(2, :half_up)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Grand total including VAT — computed from per-line gross amounts to avoid
|
|
78
|
+
# double-rounding through already-rounded net_total/tax_total (EN 16931 BR-CO-13).
|
|
79
|
+
def gross_total
|
|
80
|
+
lines.sum(BigDecimal("0"), &:gross_amount).round(2, :half_up)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Amount due (same as gross_total; override for prepayments).
|
|
84
|
+
def due_amount
|
|
85
|
+
gross_total
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def compute_tax_breakdown(lines)
|
|
91
|
+
grouped = lines.group_by { |l| [l.vat_rate, l.category] }
|
|
92
|
+
grouped.map do |(rate, category), rate_lines|
|
|
93
|
+
taxable = rate_lines.sum(BigDecimal("0"), &:net_amount).round(2, :half_up)
|
|
94
|
+
tax_amt = rate_lines.sum(BigDecimal("0"), &:vat_amount).round(2, :half_up)
|
|
95
|
+
Tax.new(rate: rate, taxable_amount: taxable, tax_amount: tax_amt, category: category)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Einvoicing
|
|
4
|
+
# ActiveSupport::Concern that adds e-invoicing capabilities to an
|
|
5
|
+
# ActiveRecord model.
|
|
6
|
+
#
|
|
7
|
+
# The model must respond to the following methods (columns or Ruby methods):
|
|
8
|
+
# invoice_number, issue_date, due_date, currency,
|
|
9
|
+
# einvoicing_seller, einvoicing_buyer, einvoicing_lines
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# class Invoice < ApplicationRecord
|
|
13
|
+
# include Einvoicing::Invoiceable
|
|
14
|
+
#
|
|
15
|
+
# def einvoicing_seller
|
|
16
|
+
# Einvoicing::Party.new(
|
|
17
|
+
# name: company.name,
|
|
18
|
+
# siren: company.siren,
|
|
19
|
+
# vat_number: company.vat_number,
|
|
20
|
+
# street: company.street,
|
|
21
|
+
# city: company.city,
|
|
22
|
+
# postal_code: company.postal_code
|
|
23
|
+
# )
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# def einvoicing_buyer
|
|
27
|
+
# Einvoicing::Party.new(name: client.name, siren: client.siren)
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# def einvoicing_lines
|
|
31
|
+
# line_items.map do |li|
|
|
32
|
+
# Einvoicing::LineItem.new(
|
|
33
|
+
# description: li.description,
|
|
34
|
+
# quantity: li.quantity,
|
|
35
|
+
# unit_price: li.unit_price,
|
|
36
|
+
# vat_rate: li.vat_rate
|
|
37
|
+
# )
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
module Invoiceable
|
|
42
|
+
def self.included(base)
|
|
43
|
+
base.extend(ClassMethods)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module ClassMethods
|
|
47
|
+
# Override to use a different validator. Defaults to FR.
|
|
48
|
+
# @example
|
|
49
|
+
# self.einvoicing_validator = Einvoicing::Validators::DE
|
|
50
|
+
def einvoicing_validator
|
|
51
|
+
@einvoicing_validator || Einvoicing::Validators::FR
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def einvoicing_validator=(validator)
|
|
55
|
+
@einvoicing_validator = validator
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Build an Einvoicing::Invoice from this record.
|
|
60
|
+
# @return [Einvoicing::Invoice]
|
|
61
|
+
def to_einvoice
|
|
62
|
+
has_due_date = self.class.respond_to?(:column_names) \
|
|
63
|
+
? self.class.column_names.include?("due_date") \
|
|
64
|
+
: respond_to?(:due_date)
|
|
65
|
+
|
|
66
|
+
Einvoicing::Invoice.new(
|
|
67
|
+
invoice_number: invoice_number,
|
|
68
|
+
issue_date: issue_date,
|
|
69
|
+
due_date: has_due_date ? due_date : nil,
|
|
70
|
+
currency: respond_to?(:currency) ? (currency || "EUR") : "EUR",
|
|
71
|
+
seller: einvoicing_seller,
|
|
72
|
+
buyer: einvoicing_buyer,
|
|
73
|
+
lines: einvoicing_lines
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Generate CII D16B XML string.
|
|
78
|
+
# @return [String]
|
|
79
|
+
def to_cii_xml
|
|
80
|
+
Einvoicing::Formats::CII.generate(to_einvoice)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Generate UBL 2.1 XML string.
|
|
84
|
+
# @return [String]
|
|
85
|
+
def to_ubl_xml
|
|
86
|
+
Einvoicing::Formats::UBL.generate(to_einvoice)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Generate Factur-X PDF by embedding CII XML into an existing PDF blob.
|
|
90
|
+
# @param pdf_data [String] original PDF binary
|
|
91
|
+
# @return [String] Factur-X PDF binary
|
|
92
|
+
def to_facturx(pdf_data)
|
|
93
|
+
xml = to_cii_xml
|
|
94
|
+
Einvoicing::Formats::FacturX.embed(pdf_data, xml)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Validate the invoice using the configured validator.
|
|
98
|
+
# @return [Array<Hash>] list of error hashes ({ field:, error:, message: })
|
|
99
|
+
def einvoicing_errors
|
|
100
|
+
self.class.einvoicing_validator.validate(to_einvoice)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Boolean]
|
|
104
|
+
def einvoicing_valid?
|
|
105
|
+
einvoicing_errors.empty?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Raise ValidationError unless valid.
|
|
109
|
+
def validate_einvoice!
|
|
110
|
+
self.class.einvoicing_validator.validate!(to_einvoice)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Stub: override in your model or configure an adapter.
|
|
114
|
+
# Returns a hash with :status and optionally :reference.
|
|
115
|
+
def transmit!(adapter: nil)
|
|
116
|
+
raise NotImplementedError,
|
|
117
|
+
"Configure a transmission adapter or override #transmit! in #{self.class}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|