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.
@@ -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