einvoicing 0.5.0 → 0.7.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 +18 -0
- data/LICENSE +21 -0
- data/README.md +18 -4
- data/config/locales/einvoicing.en.yml +8 -0
- data/config/locales/einvoicing.fr.yml +8 -0
- data/lib/einvoicing/formats/cii.rb +15 -12
- data/lib/einvoicing/formats/facturx.rb +3 -3
- data/lib/einvoicing/formats/ubl.rb +4 -0
- data/lib/einvoicing/i18n.rb +18 -7
- data/lib/einvoicing/invoice.rb +11 -6
- data/lib/einvoicing/party.rb +4 -4
- data/lib/einvoicing/ppf/client.rb +3 -3
- data/lib/einvoicing/ppf/invoice_adapter.rb +3 -3
- data/lib/einvoicing/ppf/submitter.rb +5 -5
- data/lib/einvoicing/tax.rb +1 -1
- data/lib/einvoicing/validators/fr.rb +10 -3
- data/lib/einvoicing/validators/peppol.rb +4 -4
- data/lib/einvoicing/version.rb +1 -1
- data/lib/einvoicing/xml_builder.rb +1 -1
- data/lib/einvoicing.rb +6 -5
- metadata +48 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7581f06decdc863a7f8c7509a3ff696f73ca8d223579aa4695a3eee160f3e196
|
|
4
|
+
data.tar.gz: 1355d8b9034f874d7f3bd7c38315428c90477c6f987ef9642e0deca071f17587
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0146294e7c5ea5515211eabf88d54dde12525052409b69a29aa4206df02762622a96b92bfd2c9e5afef820a50b4c34d939ba2e52baa4c217adf843b324bb90b6'
|
|
7
|
+
data.tar.gz: 19dac11dd07c6dafb9856fe0878201de2ddc259678bbddef425107413a2d4649058568d6fdae988002df71223b4165b1aee38d3692bf4f7ec390a874b61a8b45
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.7.0] - 2026-07-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `prepaid_amount` on `Invoice` (BT-113 `TotalPrepaidAmount` in CII / `PrepaidAmount` in UBL) so a retained/prepaid amount (e.g. retenue de garantie) reduces the amount due (BR-CO-16: `DuePayableAmount = GrandTotal − TotalPrepaidAmount`) without affecting the taxable base or VAT totals
|
|
14
|
+
- CII profile support: `:chorus_pro` and `:en16931` selectable per invoice
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- CII `schemeID` SIRET correctly emitted for Chorus Pro
|
|
18
|
+
- `PaymentMeans` is now required when generating CII/UBL
|
|
19
|
+
|
|
20
|
+
## [0.6.0] - 2026-03-14
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- Codebase aligned on `rubocop-rails-omakase` standards
|
|
24
|
+
- Locale strings extracted into `config/locales/*.yml` (English + French)
|
|
25
|
+
|
|
8
26
|
## [0.5.0] - 2026-03-14
|
|
9
27
|
|
|
10
28
|
### Fixed
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SXN Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
CHANGED
|
@@ -55,7 +55,7 @@ seller = Einvoicing::Party.new(
|
|
|
55
55
|
|
|
56
56
|
buyer = Einvoicing::Party.new(
|
|
57
57
|
name: "Gecobat",
|
|
58
|
-
street: "12
|
|
58
|
+
street: "12 Construction Street",
|
|
59
59
|
city: "Paris",
|
|
60
60
|
postal_code: "75001",
|
|
61
61
|
country_code: "FR",
|
|
@@ -65,13 +65,13 @@ buyer = Einvoicing::Party.new(
|
|
|
65
65
|
|
|
66
66
|
lines = [
|
|
67
67
|
Einvoicing::LineItem.new(
|
|
68
|
-
description: "
|
|
68
|
+
description: "Backend development — REST API (fixed fee)",
|
|
69
69
|
quantity: 1,
|
|
70
70
|
unit_price: BigDecimal("2500.00"),
|
|
71
71
|
vat_rate: 0.20
|
|
72
72
|
),
|
|
73
73
|
Einvoicing::LineItem.new(
|
|
74
|
-
description: "
|
|
74
|
+
description: "Factur-X integration",
|
|
75
75
|
quantity: 5,
|
|
76
76
|
unit_price: BigDecimal("350.00"),
|
|
77
77
|
vat_rate: 0.20
|
|
@@ -86,7 +86,7 @@ invoice = Einvoicing::Invoice.new(
|
|
|
86
86
|
buyer: buyer,
|
|
87
87
|
lines: lines,
|
|
88
88
|
payment_reference: "FAC-2024-0042",
|
|
89
|
-
note: "30
|
|
89
|
+
note: "Net 30",
|
|
90
90
|
payment_means_code: 30,
|
|
91
91
|
iban: "FR7630006000011234567890189",
|
|
92
92
|
bic: "BNPAFRPP"
|
|
@@ -175,6 +175,20 @@ ubl = Einvoicing::Formats::UBL.generate(invoice)
|
|
|
175
175
|
|
|
176
176
|
Produces a UBL 2.1 `Invoice` document with Peppol BIS Billing 3.0 customization ID.
|
|
177
177
|
|
|
178
|
+
## Chorus Pro / PPF (France)
|
|
179
|
+
|
|
180
|
+
When submitting to Chorus Pro (French B2G portal), generate CII XML with the `:chorus_pro` profile:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
xml = Einvoicing.xml(invoice, format: :cii, profile: :chorus_pro)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Chorus Pro-specific requirements:
|
|
187
|
+
- `schemeID` for SIRET identifiers must be `"SIRET"` (not the ISO 6523 code `"0002"`)
|
|
188
|
+
- `SpecifiedTradeSettlementPaymentMeans` is mandatory — set `payment_means_code:` on the invoice (30 = credit transfer)
|
|
189
|
+
- Invoice ID (`invoice_number`) must not exceed 20 characters
|
|
190
|
+
- Use `einvoicing-connect` for the actual API submission
|
|
191
|
+
|
|
178
192
|
## Rails Integration
|
|
179
193
|
|
|
180
194
|
Include `Einvoicing::Invoiceable` in your ActiveRecord model and implement three methods:
|
|
@@ -10,6 +10,8 @@ en:
|
|
|
10
10
|
original_invoice_number_missing: "Original invoice number is required for credit notes"
|
|
11
11
|
iban_invalid: "IBAN format or checksum is invalid"
|
|
12
12
|
bic_invalid: "BIC format is invalid (8 or 11 characters)"
|
|
13
|
+
prepaid_amount_negative: "Prepaid amount cannot be negative"
|
|
14
|
+
prepaid_amount_exceeds_total: "Prepaid amount cannot exceed the invoice total"
|
|
13
15
|
seller:
|
|
14
16
|
name_missing: "Seller name is required"
|
|
15
17
|
siren_invalid: "Seller SIREN must be 9 digits (Luhn check failed)"
|
|
@@ -25,3 +27,9 @@ en:
|
|
|
25
27
|
quantity_invalid: "Line %{index}: quantity must be positive"
|
|
26
28
|
unit_price_invalid: "Line %{index}: unit price must be non-negative"
|
|
27
29
|
vat_rate_invalid: "Line %{index}: VAT rate must be a known French rate (0%%, 5.5%%, 10%%, 20%%)"
|
|
30
|
+
formats:
|
|
31
|
+
unknown_format: "Unknown format: %{fmt}. Use :cii or :ubl"
|
|
32
|
+
unknown_market: "Unknown market: %{market}. Use :fr"
|
|
33
|
+
invalid_pdf: "pdf_data does not appear to be a valid PDF (missing %PDF- magic bytes)"
|
|
34
|
+
tax:
|
|
35
|
+
invalid_rate: "rate must be >= 0, got %{rate}"
|
|
@@ -10,6 +10,8 @@ fr:
|
|
|
10
10
|
original_invoice_number_missing: "Le numéro de facture d'origine est requis pour les avoirs"
|
|
11
11
|
iban_invalid: "Le format ou la somme de contrôle de l'IBAN est invalide"
|
|
12
12
|
bic_invalid: "Le format du BIC est invalide (8 ou 11 caractères)"
|
|
13
|
+
prepaid_amount_negative: "Le montant déjà payé ne peut pas être négatif"
|
|
14
|
+
prepaid_amount_exceeds_total: "Le montant déjà payé ne peut pas dépasser le total de la facture"
|
|
13
15
|
seller:
|
|
14
16
|
name_missing: "Le nom du vendeur est requis"
|
|
15
17
|
siren_invalid: "Le SIREN du vendeur doit comporter 9 chiffres (vérification Luhn échouée)"
|
|
@@ -25,3 +27,9 @@ fr:
|
|
|
25
27
|
quantity_invalid: "Ligne %{index} : la quantité doit être positive"
|
|
26
28
|
unit_price_invalid: "Ligne %{index} : le prix unitaire doit être non négatif"
|
|
27
29
|
vat_rate_invalid: "Ligne %{index} : le taux de TVA doit être un taux français standard (0%%, 5,5%%, 10%%, 20%%)"
|
|
30
|
+
formats:
|
|
31
|
+
unknown_format: "Format inconnu : %{fmt}. Utilisez :cii ou :ubl"
|
|
32
|
+
unknown_market: "Marché inconnu : %{market}. Utilisez :fr"
|
|
33
|
+
invalid_pdf: "pdf_data ne semble pas être un PDF valide (octets magiques %PDF- manquants)"
|
|
34
|
+
tax:
|
|
35
|
+
invalid_rate: "le taux doit être >= 0, valeur reçue : %{rate}"
|
|
@@ -16,7 +16,7 @@ module Einvoicing
|
|
|
16
16
|
UDT_NS = "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
|
|
17
17
|
QDT_NS = "urn:un:unece:uncefact:data:standard:QualifiedDataType:100"
|
|
18
18
|
|
|
19
|
-
def self.generate(invoice)
|
|
19
|
+
def self.generate(invoice, profile: :en16931)
|
|
20
20
|
b = XMLBuilder.new
|
|
21
21
|
b.tag(
|
|
22
22
|
"rsm:CrossIndustryInvoice",
|
|
@@ -28,7 +28,7 @@ module Einvoicing
|
|
|
28
28
|
) do
|
|
29
29
|
exchanged_document_context(b)
|
|
30
30
|
exchanged_document(b, invoice)
|
|
31
|
-
supply_chain_trade_transaction(b, invoice)
|
|
31
|
+
supply_chain_trade_transaction(b, invoice, profile)
|
|
32
32
|
end
|
|
33
33
|
b.to_xml
|
|
34
34
|
end
|
|
@@ -53,8 +53,8 @@ module Einvoicing
|
|
|
53
53
|
end
|
|
54
54
|
if invoice.document_type == :credit_note && invoice.original_invoice_number
|
|
55
55
|
b.tag("ram:IncludedNote") do
|
|
56
|
-
note = "
|
|
57
|
-
note += "
|
|
56
|
+
note = "Credit note for invoice #{invoice.original_invoice_number}"
|
|
57
|
+
note += " dated #{invoice.original_invoice_date.strftime('%d/%m/%Y')}" if invoice.original_invoice_date
|
|
58
58
|
b.text("ram:Content", note)
|
|
59
59
|
end
|
|
60
60
|
end
|
|
@@ -67,12 +67,12 @@ module Einvoicing
|
|
|
67
67
|
end
|
|
68
68
|
private_class_method :exchanged_document
|
|
69
69
|
|
|
70
|
-
def self.supply_chain_trade_transaction(b, invoice)
|
|
70
|
+
def self.supply_chain_trade_transaction(b, invoice, profile)
|
|
71
71
|
b.tag("rsm:SupplyChainTradeTransaction") do
|
|
72
72
|
invoice.lines.each_with_index do |line, idx|
|
|
73
73
|
trade_line_item(b, line, idx + 1, invoice.currency)
|
|
74
74
|
end
|
|
75
|
-
header_trade_agreement(b, invoice)
|
|
75
|
+
header_trade_agreement(b, invoice, profile)
|
|
76
76
|
b.tag("ram:ApplicableHeaderTradeDelivery")
|
|
77
77
|
header_trade_settlement(b, invoice)
|
|
78
78
|
end
|
|
@@ -109,21 +109,23 @@ module Einvoicing
|
|
|
109
109
|
end
|
|
110
110
|
private_class_method :trade_line_item
|
|
111
111
|
|
|
112
|
-
def self.header_trade_agreement(b, invoice)
|
|
112
|
+
def self.header_trade_agreement(b, invoice, profile)
|
|
113
113
|
b.tag("ram:ApplicableHeaderTradeAgreement") do
|
|
114
114
|
# BuyerReference must be first in the sequence (EN 16931 BR-10 / XSD order).
|
|
115
115
|
b.text("ram:BuyerReference", invoice.payment_reference || "")
|
|
116
|
-
b.tag("ram:SellerTradeParty") { party_xml(b, invoice.seller) }
|
|
117
|
-
b.tag("ram:BuyerTradeParty") { party_xml(b, invoice.buyer) }
|
|
116
|
+
b.tag("ram:SellerTradeParty") { party_xml(b, invoice.seller, profile) }
|
|
117
|
+
b.tag("ram:BuyerTradeParty") { party_xml(b, invoice.buyer, profile) }
|
|
118
118
|
end
|
|
119
119
|
end
|
|
120
120
|
private_class_method :header_trade_agreement
|
|
121
121
|
|
|
122
|
-
def self.party_xml(b, party)
|
|
122
|
+
def self.party_xml(b, party, profile)
|
|
123
123
|
b.text("ram:Name", party.name)
|
|
124
|
-
|
|
124
|
+
legal_id = party.siret || party.siren
|
|
125
|
+
if legal_id
|
|
125
126
|
b.tag("ram:SpecifiedLegalOrganization") do
|
|
126
|
-
|
|
127
|
+
scheme = (profile == :chorus_pro && party.siret) ? "SIRET" : "0002"
|
|
128
|
+
b.text("ram:ID", legal_id, "schemeID" => scheme)
|
|
127
129
|
end
|
|
128
130
|
end
|
|
129
131
|
b.tag("ram:PostalTradeAddress") do
|
|
@@ -190,6 +192,7 @@ module Einvoicing
|
|
|
190
192
|
b.text("ram:TaxTotalAmount", format_amount(invoice.tax_total),
|
|
191
193
|
"currencyID" => invoice.currency)
|
|
192
194
|
b.text("ram:GrandTotalAmount", format_amount(invoice.gross_total))
|
|
195
|
+
b.text("ram:TotalPrepaidAmount", format_amount(invoice.prepaid_amount)) if invoice.prepaid_amount.positive?
|
|
193
196
|
b.text("ram:DuePayableAmount", format_amount(invoice.due_amount))
|
|
194
197
|
end
|
|
195
198
|
end
|
|
@@ -31,7 +31,7 @@ module Einvoicing
|
|
|
31
31
|
# @return [String] binary Factur-X PDF/A-3 content
|
|
32
32
|
def self.embed(pdf_data, xml_string, profile: CONFORMANCE)
|
|
33
33
|
unless pdf_data.to_s.b.start_with?("%PDF-")
|
|
34
|
-
raise ArgumentError, "
|
|
34
|
+
raise ArgumentError, Einvoicing::I18n.t("formats.invalid_pdf")
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
require "hexapdf"
|
|
@@ -66,7 +66,7 @@ module Einvoicing
|
|
|
66
66
|
names_dict[:EmbeddedFiles][:Names] << FILENAME << filespec
|
|
67
67
|
|
|
68
68
|
# 3. Set AF array on the catalog.
|
|
69
|
-
doc.catalog[:AF] = [filespec]
|
|
69
|
+
doc.catalog[:AF] = [ filespec ]
|
|
70
70
|
|
|
71
71
|
# 4. Add OutputIntent (required for PDF/A-3 conformance).
|
|
72
72
|
add_output_intent(doc)
|
|
@@ -183,7 +183,7 @@ module Einvoicing
|
|
|
183
183
|
DestOutputProfile: icc_stream
|
|
184
184
|
})
|
|
185
185
|
|
|
186
|
-
doc.catalog[:OutputIntents] = [output_intent]
|
|
186
|
+
doc.catalog[:OutputIntents] = [ output_intent ]
|
|
187
187
|
end
|
|
188
188
|
|
|
189
189
|
private_class_method def self.md5(bytes)
|
|
@@ -159,6 +159,10 @@ module Einvoicing
|
|
|
159
159
|
"currencyID" => invoice.currency)
|
|
160
160
|
b.text("cbc:TaxInclusiveAmount", format_amount(invoice.gross_total),
|
|
161
161
|
"currencyID" => invoice.currency)
|
|
162
|
+
if invoice.prepaid_amount.positive?
|
|
163
|
+
b.text("cbc:PrepaidAmount", format_amount(invoice.prepaid_amount),
|
|
164
|
+
"currencyID" => invoice.currency)
|
|
165
|
+
end
|
|
162
166
|
b.text("cbc:PayableAmount", format_amount(invoice.due_amount),
|
|
163
167
|
"currencyID" => invoice.currency)
|
|
164
168
|
end
|
data/lib/einvoicing/i18n.rb
CHANGED
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "i18n"
|
|
4
|
+
|
|
3
5
|
module Einvoicing
|
|
4
|
-
# Thin wrapper around ::I18n
|
|
5
|
-
#
|
|
6
|
-
#
|
|
6
|
+
# Thin wrapper around ::I18n for gem-internal translations.
|
|
7
|
+
# Loads the gem's own locale files on setup; in Rails apps the engine
|
|
8
|
+
# already handles the load_path, so duplicates are skipped.
|
|
7
9
|
module I18n
|
|
8
10
|
DEFAULT_LOCALE = :en
|
|
11
|
+
LOCALES_PATH = File.expand_path("../../config/locales", __dir__)
|
|
9
12
|
|
|
10
|
-
def self.
|
|
11
|
-
|
|
13
|
+
def self.setup
|
|
14
|
+
locale_files = Dir[File.join(LOCALES_PATH, "*.yml")]
|
|
15
|
+
new_files = locale_files - ::I18n.load_path
|
|
16
|
+
return if new_files.empty?
|
|
17
|
+
|
|
18
|
+
::I18n.load_path += new_files
|
|
19
|
+
::I18n.backend.load_translations
|
|
20
|
+
end
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
def self.t(key, **options)
|
|
23
|
+
locale = options.delete(:locale) { ::I18n.locale }
|
|
14
24
|
::I18n.t("einvoicing.#{key}", locale: locale, **options)
|
|
15
25
|
rescue ::I18n::MissingTranslationData
|
|
16
|
-
# Fallback to English if translation missing in current locale
|
|
17
26
|
::I18n.t("einvoicing.#{key}", locale: DEFAULT_LOCALE, **options)
|
|
18
27
|
rescue StandardError
|
|
19
28
|
key.to_s
|
|
20
29
|
end
|
|
21
30
|
end
|
|
22
31
|
end
|
|
32
|
+
|
|
33
|
+
Einvoicing::I18n.setup
|
data/lib/einvoicing/invoice.rb
CHANGED
|
@@ -35,13 +35,15 @@ module Einvoicing
|
|
|
35
35
|
:bic,
|
|
36
36
|
:document_type,
|
|
37
37
|
:original_invoice_number,
|
|
38
|
-
:original_invoice_date
|
|
38
|
+
:original_invoice_date,
|
|
39
|
+
:prepaid_amount
|
|
39
40
|
) do
|
|
40
41
|
def initialize(invoice_number:, issue_date:, seller:, buyer:, lines:,
|
|
41
42
|
due_date: nil, currency: "EUR", tax_currency: nil, tax_breakdown: nil,
|
|
42
43
|
payment_reference: nil, note: nil,
|
|
43
44
|
payment_means_code: nil, iban: nil, bic: nil,
|
|
44
|
-
document_type: :invoice, original_invoice_number: nil, original_invoice_date: nil
|
|
45
|
+
document_type: :invoice, original_invoice_number: nil, original_invoice_date: nil,
|
|
46
|
+
prepaid_amount: BigDecimal(0))
|
|
45
47
|
computed_breakdown = tax_breakdown || compute_tax_breakdown(lines)
|
|
46
48
|
super(
|
|
47
49
|
invoice_number: invoice_number,
|
|
@@ -60,7 +62,8 @@ module Einvoicing
|
|
|
60
62
|
bic: bic,
|
|
61
63
|
document_type: document_type,
|
|
62
64
|
original_invoice_number: original_invoice_number,
|
|
63
|
-
original_invoice_date: original_invoice_date
|
|
65
|
+
original_invoice_date: original_invoice_date,
|
|
66
|
+
prepaid_amount: prepaid_amount.nil? ? BigDecimal(0) : BigDecimal(prepaid_amount.to_s)
|
|
64
67
|
)
|
|
65
68
|
end
|
|
66
69
|
|
|
@@ -80,15 +83,17 @@ module Einvoicing
|
|
|
80
83
|
lines.sum(BigDecimal("0"), &:gross_amount).round(2, :half_up)
|
|
81
84
|
end
|
|
82
85
|
|
|
83
|
-
# Amount due
|
|
86
|
+
# Amount due after deducting any retained/prepaid amount (BT-113).
|
|
87
|
+
# VAT remains due on the full gross_total — only the payable balance is reduced
|
|
88
|
+
# (EN 16931 BR-CO-16: DuePayableAmount = GrandTotal − TotalPrepaidAmount).
|
|
84
89
|
def due_amount
|
|
85
|
-
gross_total
|
|
90
|
+
gross_total - prepaid_amount
|
|
86
91
|
end
|
|
87
92
|
|
|
88
93
|
private
|
|
89
94
|
|
|
90
95
|
def compute_tax_breakdown(lines)
|
|
91
|
-
grouped = lines.group_by { |l| [l.vat_rate, l.category] }
|
|
96
|
+
grouped = lines.group_by { |l| [ l.vat_rate, l.category ] }
|
|
92
97
|
grouped.map do |(rate, category), rate_lines|
|
|
93
98
|
taxable = rate_lines.sum(BigDecimal("0"), &:net_amount).round(2, :half_up)
|
|
94
99
|
tax_amt = rate_lines.sum(BigDecimal("0"), &:vat_amount).round(2, :half_up)
|
data/lib/einvoicing/party.rb
CHANGED
|
@@ -27,13 +27,13 @@ module Einvoicing
|
|
|
27
27
|
resolved_endpoint_id = endpoint_id || siret || email
|
|
28
28
|
resolved_endpoint_scheme = if endpoint_scheme
|
|
29
29
|
endpoint_scheme
|
|
30
|
-
|
|
30
|
+
elsif endpoint_id
|
|
31
31
|
nil # caller must supply scheme when explicit
|
|
32
|
-
|
|
32
|
+
elsif siret
|
|
33
33
|
"0002" # SIRET scheme — Peppol EAS FR standard
|
|
34
|
-
|
|
34
|
+
elsif email
|
|
35
35
|
"EM" # email fallback (not in Peppol EAS, use for non-Peppol)
|
|
36
|
-
|
|
36
|
+
end
|
|
37
37
|
super(name: name, street: street, city: city, postal_code: postal_code,
|
|
38
38
|
country_code: country_code, siren: siren, siret: siret,
|
|
39
39
|
vat_number: vat_number, email: email,
|
|
@@ -46,9 +46,9 @@ module Einvoicing
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
# POST /v1/soumettre/factures — submit an invoice.
|
|
49
|
-
#
|
|
50
|
-
def submit_invoice(
|
|
51
|
-
post("/v1/soumettre/factures",
|
|
49
|
+
# invoice_hash: Hash matching the Chorus Pro invoice schema.
|
|
50
|
+
def submit_invoice(invoice_hash)
|
|
51
|
+
post("/v1/soumettre/factures", invoice_hash)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
private
|
|
@@ -8,12 +8,12 @@ module Einvoicing
|
|
|
8
8
|
# @param invoice [Einvoicing::Invoice]
|
|
9
9
|
# @param id_structure_cpp [Integer] from find_structure()
|
|
10
10
|
# @param code_service [String, nil] from list_services() — optional
|
|
11
|
-
# @param
|
|
12
|
-
def self.to_chorus_payload(invoice, id_structure_cpp:, code_service: nil,
|
|
11
|
+
# @param engagement_number [String, nil] buyer PO/engagement number — optional for B2B
|
|
12
|
+
def self.to_chorus_payload(invoice, id_structure_cpp:, code_service: nil, engagement_number: nil)
|
|
13
13
|
{
|
|
14
14
|
idStructureCPP: id_structure_cpp,
|
|
15
15
|
codeService: code_service,
|
|
16
|
-
numeroEngagement:
|
|
16
|
+
numeroEngagement: engagement_number,
|
|
17
17
|
cadreFacturation: {
|
|
18
18
|
codeCadreFacturation: "FACTURE_FOURNISSEUR",
|
|
19
19
|
codeServiceValideur: nil
|
|
@@ -12,17 +12,17 @@ module Einvoicing
|
|
|
12
12
|
#
|
|
13
13
|
# @param invoice [Einvoicing::Invoice]
|
|
14
14
|
# @param code_service [String, nil] optional service code from list_services
|
|
15
|
-
# @param
|
|
16
|
-
def submit(invoice, code_service: nil,
|
|
15
|
+
# @param engagement_number [String, nil] optional engagement number
|
|
16
|
+
def submit(invoice, code_service: nil, engagement_number: nil)
|
|
17
17
|
structure = @client.find_structure(siret: invoice.buyer.siret)
|
|
18
18
|
id_structure = structure["idStructureCPP"] || structure.dig("parametres", "idStructureCPP")
|
|
19
19
|
raise ValidationError, "Buyer SIRET #{invoice.buyer.siret} not found in Chorus Pro" unless id_structure
|
|
20
20
|
|
|
21
21
|
payload = InvoiceAdapter.to_chorus_payload(
|
|
22
22
|
invoice,
|
|
23
|
-
id_structure_cpp:
|
|
24
|
-
code_service:
|
|
25
|
-
|
|
23
|
+
id_structure_cpp: id_structure,
|
|
24
|
+
code_service: code_service,
|
|
25
|
+
engagement_number: engagement_number
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
@client.submit_invoice(payload)
|
data/lib/einvoicing/tax.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Einvoicing
|
|
|
11
11
|
# @param tax_amount [Numeric] VAT amount for this rate
|
|
12
12
|
# @param category [Symbol, nil] nil for standard/zero, :reverse_charge for AE
|
|
13
13
|
def initialize(rate:, taxable_amount:, tax_amount:, category: nil)
|
|
14
|
-
raise ArgumentError, "
|
|
14
|
+
raise ArgumentError, Einvoicing::I18n.t("tax.invalid_rate", rate: rate) if rate.to_f.negative?
|
|
15
15
|
|
|
16
16
|
super
|
|
17
17
|
end
|
|
@@ -124,6 +124,13 @@ module Einvoicing
|
|
|
124
124
|
errors << { field: :bic, error: :bic_invalid,
|
|
125
125
|
message: Einvoicing::I18n.t("errors.invoice.bic_invalid") }
|
|
126
126
|
end
|
|
127
|
+
if invoice.prepaid_amount.negative?
|
|
128
|
+
errors << { field: :prepaid_amount, error: :prepaid_amount_negative,
|
|
129
|
+
message: Einvoicing::I18n.t("errors.invoice.prepaid_amount_negative") }
|
|
130
|
+
elsif invoice.prepaid_amount > invoice.gross_total
|
|
131
|
+
errors << { field: :prepaid_amount, error: :prepaid_amount_exceeds_total,
|
|
132
|
+
message: Einvoicing::I18n.t("errors.invoice.prepaid_amount_exceeds_total") }
|
|
133
|
+
end
|
|
127
134
|
errors
|
|
128
135
|
end
|
|
129
136
|
private_class_method :validate_invoice_fields
|
|
@@ -159,8 +166,8 @@ module Einvoicing
|
|
|
159
166
|
|
|
160
167
|
def self.validate_lines(lines)
|
|
161
168
|
if lines.nil? || lines.empty?
|
|
162
|
-
return [{ field: :lines, error: :lines_empty,
|
|
163
|
-
message: Einvoicing::I18n.t("errors.invoice.lines_empty") }]
|
|
169
|
+
return [ { field: :lines, error: :lines_empty,
|
|
170
|
+
message: Einvoicing::I18n.t("errors.invoice.lines_empty") } ]
|
|
164
171
|
end
|
|
165
172
|
|
|
166
173
|
lines.each_with_index.flat_map do |line, idx|
|
|
@@ -178,7 +185,7 @@ module Einvoicing
|
|
|
178
185
|
{ field: :"line_#{n}_unit_price", error: :unit_price_invalid,
|
|
179
186
|
message: Einvoicing::I18n.t("errors.line.unit_price_invalid", index: n) }
|
|
180
187
|
end),
|
|
181
|
-
(unless [0.0, 0.055, 0.10, 0.20].include?(line.vat_rate.to_f.round(3))
|
|
188
|
+
(unless [ 0.0, 0.055, 0.10, 0.20 ].include?(line.vat_rate.to_f.round(3))
|
|
182
189
|
{ field: :"line_#{n}_vat_rate", error: :vat_rate_invalid,
|
|
183
190
|
message: Einvoicing::I18n.t("errors.line.vat_rate_invalid", index: n) }
|
|
184
191
|
end)
|
|
@@ -86,18 +86,18 @@ module Einvoicing
|
|
|
86
86
|
private_class_method :download
|
|
87
87
|
|
|
88
88
|
def self.run_saxon(xml_string)
|
|
89
|
-
input = Tempfile.new(["peppol-input", ".xml"])
|
|
90
|
-
output = Tempfile.new(["peppol-output", ".svrl"])
|
|
89
|
+
input = Tempfile.new([ "peppol-input", ".xml" ])
|
|
90
|
+
output = Tempfile.new([ "peppol-output", ".svrl" ])
|
|
91
91
|
|
|
92
92
|
begin
|
|
93
93
|
input.write(xml_string)
|
|
94
94
|
input.close
|
|
95
95
|
output.close
|
|
96
96
|
|
|
97
|
-
cmd = ["java", "-jar", SAXON_JAR,
|
|
97
|
+
cmd = [ "java", "-jar", SAXON_JAR,
|
|
98
98
|
"-s:#{input.path}",
|
|
99
99
|
"-xsl:#{XSLT_PATH}",
|
|
100
|
-
"-o:#{output.path}"]
|
|
100
|
+
"-o:#{output.path}" ]
|
|
101
101
|
|
|
102
102
|
_, stderr, status = Open3.capture3(*cmd)
|
|
103
103
|
|
data/lib/einvoicing/version.rb
CHANGED
data/lib/einvoicing.rb
CHANGED
|
@@ -56,12 +56,13 @@ module Einvoicing
|
|
|
56
56
|
# Generate XML from an invoice.
|
|
57
57
|
# @param invoice [Einvoicing::Invoice]
|
|
58
58
|
# @param format [Symbol] :cii (default, Factur-X) or :ubl (Peppol BIS 3.0)
|
|
59
|
+
# @param profile [Symbol] :en16931 (default) or :chorus_pro (French B2G portal)
|
|
59
60
|
# @return [String] XML document
|
|
60
|
-
def self.xml(invoice, format: :cii)
|
|
61
|
+
def self.xml(invoice, format: :cii, profile: :en16931)
|
|
61
62
|
case format
|
|
62
|
-
when :cii then Formats::CII.generate(invoice)
|
|
63
|
+
when :cii then Formats::CII.generate(invoice, profile: profile)
|
|
63
64
|
when :ubl then Formats::UBL.generate(invoice)
|
|
64
|
-
else raise ArgumentError, "
|
|
65
|
+
else raise ArgumentError, Einvoicing::I18n.t("formats.unknown_format", fmt: format.inspect)
|
|
65
66
|
end
|
|
66
67
|
end
|
|
67
68
|
|
|
@@ -81,7 +82,7 @@ module Einvoicing
|
|
|
81
82
|
def self.validate(invoice, market: :fr)
|
|
82
83
|
case market
|
|
83
84
|
when :fr then Validators::FR.validate(invoice)
|
|
84
|
-
else raise ArgumentError, "
|
|
85
|
+
else raise ArgumentError, Einvoicing::I18n.t("formats.unknown_market", market: market.inspect)
|
|
85
86
|
end
|
|
86
87
|
end
|
|
87
88
|
|
|
@@ -98,6 +99,6 @@ module Einvoicing
|
|
|
98
99
|
pdf_out = pdf ? embed(pdf, xml_str) : nil
|
|
99
100
|
{ valid: errors.empty?, errors: errors, xml: xml_str, pdf: pdf_out }
|
|
100
101
|
rescue StandardError => e
|
|
101
|
-
{ valid: false, errors: [{ field: :unknown, error: :exception, message: e.message }], xml: nil, pdf: nil }
|
|
102
|
+
{ valid: false, errors: [ { field: :unknown, error: :exception, message: e.message } ], xml: nil, pdf: nil }
|
|
102
103
|
end
|
|
103
104
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: einvoicing
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nathan Le Ray
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: hexapdf
|
|
@@ -24,6 +23,20 @@ dependencies:
|
|
|
24
23
|
- - "~>"
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
25
|
version: '1.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: i18n
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.0'
|
|
27
40
|
- !ruby/object:Gem::Dependency
|
|
28
41
|
name: rspec
|
|
29
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -44,14 +57,42 @@ dependencies:
|
|
|
44
57
|
requirements:
|
|
45
58
|
- - "~>"
|
|
46
59
|
- !ruby/object:Gem::Version
|
|
47
|
-
version: '1.
|
|
60
|
+
version: '1.70'
|
|
48
61
|
type: :development
|
|
49
62
|
prerelease: false
|
|
50
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
64
|
requirements:
|
|
52
65
|
- - "~>"
|
|
53
66
|
- !ruby/object:Gem::Version
|
|
54
|
-
version: '1.
|
|
67
|
+
version: '1.70'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rubocop-rails-omakase
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rubocop-rspec
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '3.0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '3.0'
|
|
55
96
|
- !ruby/object:Gem::Dependency
|
|
56
97
|
name: nokogiri
|
|
57
98
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -119,6 +160,7 @@ extensions: []
|
|
|
119
160
|
extra_rdoc_files: []
|
|
120
161
|
files:
|
|
121
162
|
- CHANGELOG.md
|
|
163
|
+
- LICENSE
|
|
122
164
|
- README.md
|
|
123
165
|
- config/locales/einvoicing.en.yml
|
|
124
166
|
- config/locales/einvoicing.fr.yml
|
|
@@ -158,7 +200,6 @@ metadata:
|
|
|
158
200
|
source_code_uri: https://github.com/sxnlabs/einvoicing
|
|
159
201
|
changelog_uri: https://github.com/sxnlabs/einvoicing/blob/master/CHANGELOG.md
|
|
160
202
|
documentation_uri: https://www.sxnlabs.com/en/gems/einvoicing/
|
|
161
|
-
post_install_message:
|
|
162
203
|
rdoc_options: []
|
|
163
204
|
require_paths:
|
|
164
205
|
- lib
|
|
@@ -173,8 +214,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
173
214
|
- !ruby/object:Gem::Version
|
|
174
215
|
version: '0'
|
|
175
216
|
requirements: []
|
|
176
|
-
rubygems_version:
|
|
177
|
-
signing_key:
|
|
217
|
+
rubygems_version: 4.0.16
|
|
178
218
|
specification_version: 4
|
|
179
219
|
summary: EU electronic invoicing for Ruby — EN 16931, Factur-X, UBL 2.1
|
|
180
220
|
test_files: []
|