einvoicing 0.9.0 → 0.9.2
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 +16 -0
- data/README.md +12 -2
- data/config/locales/einvoicing.en.yml +2 -2
- data/config/locales/einvoicing.fr.yml +2 -2
- data/lib/einvoicing/formats/cii.rb +19 -3
- data/lib/einvoicing/invoice.rb +2 -2
- data/lib/einvoicing/party.rb +2 -2
- data/lib/einvoicing/validators/fr.rb +27 -4
- data/lib/einvoicing/version.rb +1 -1
- data/lib/einvoicing.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ebdccf05d23870723b8c45634ecfa3f19f587c8e2bf4b3f1e3652b84a2874b13
|
|
4
|
+
data.tar.gz: f329a5f6d4a3c2ba2d7bfce7c935d81964208158f43e4760c8723d1606a3f9c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 035345b680196da8a9daf0ad6d603c7bc5da458f75ee9939d491e6a3812ef0e01d4feb492474b0ff083c129c2e5d751dcfaddd8104c0b428b057980c01347f53
|
|
7
|
+
data.tar.gz: da8d6e8c4d27a445caa3159bb866242c2da2b206421959ee32083c40e88d480a55138224aeb8bbc9f32adfbec235662338f6d019aa34c2fa4bc94462be33b2f8
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.2] - 2026-08-06
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- CII announced a SIRET under `schemeID="0002"`, which is SIRENE — the nine-digit SIREN. A fourteen-digit SIRET now goes out as `0009`, the ISO 6523 code that actually labels it. Neither the XSD nor the EN 16931 Schematron catches the mismatch, because both values are just strings to them; a Plateforme Agréée checks it, and refuses the document. Chorus Pro keeps the literal `SIRET` scheme it predates the codelist with
|
|
14
|
+
|
|
15
|
+
## [0.9.1] - 2026-08-06
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Link to the online Factur-X validator in the README — runs entirely client-side with no upload required
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- CII no longer emits an empty `ram:BuyerReference` when no payment reference is set. It falls back to the invoice number, as UBL already did — the two formats produced different documents for the same invoice, and an empty BT-10 is rejected by Chorus Pro, which requires it
|
|
22
|
+
- French VAT numbers are now checked against their control key, not just their shape. `FR83552032534` has the right form and the right SIREN but the wrong key (it is 27), and the validator accepted it — a number that VIES and any tax administration rejects. The key is derived as `(12 + 3 × (SIREN mod 97)) mod 97` and the embedded SIREN must pass Luhn
|
|
23
|
+
- The official French structure is `FR` + 2 characters + 9 digits, and those two characters may be alphanumeric. Only a numeric key follows the published formula, so an alphanumeric one is still accepted on its shape and its SIREN — refusing it would reject valid invoices
|
|
24
|
+
- Every French identifier used in the test fixtures, the sample scripts and the documented examples was regenerated: all but one carried an invalid check key or a SIREN failing Luhn, which is precisely why the gap went unnoticed
|
|
25
|
+
|
|
10
26
|
## [0.9.0] - 2026-08-05
|
|
11
27
|
|
|
12
28
|
Everything here comes from a batch of 15 deliberately hostile Factur-X invoices
|
data/README.md
CHANGED
|
@@ -19,7 +19,7 @@ This gem gives you a clean Ruby API to build compliant invoices, validate them a
|
|
|
19
19
|
- Generate **UBL 2.1** XML (Peppol BIS Billing 3.0)
|
|
20
20
|
- Generate **CII D16B** XML (EN 16931 / ZUGFeRD)
|
|
21
21
|
- Full EN 16931 **VAT category set** (S, Z, E, AE, K, G, O) with exemption reasons (BT-120 / BT-121)
|
|
22
|
-
- Validate French B2B requirements: SIREN, SIRET (Luhn), EU VAT number formats, French VAT rates including the DOM and Corsican ones
|
|
22
|
+
- Validate French B2B requirements: SIREN, SIRET (Luhn), EU VAT number formats with the French check key verified, French VAT rates including the DOM and Corsican ones
|
|
23
23
|
- Structured error reporting: `{ field:, error:, message: }` with i18n support (EN + FR)
|
|
24
24
|
- Payment means: IBAN, BIC/SWIFT, UNCL4461 type codes
|
|
25
25
|
- **Rails concern** (`Einvoicing::Invoiceable`) for ActiveRecord models
|
|
@@ -61,7 +61,7 @@ buyer = Einvoicing::Party.new(
|
|
|
61
61
|
postal_code: "75001",
|
|
62
62
|
country_code: "FR",
|
|
63
63
|
siren: "552032534",
|
|
64
|
-
vat_number: "
|
|
64
|
+
vat_number: "FR27552032534"
|
|
65
65
|
)
|
|
66
66
|
|
|
67
67
|
lines = [
|
|
@@ -145,6 +145,16 @@ errors = Einvoicing::Validators::FR.validate(invoice)
|
|
|
145
145
|
# => [{ field: :seller_siren, error: :siren_invalid, message: "Le numéro SIREN est invalide" }]
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
### Check a generated file online
|
|
149
|
+
|
|
150
|
+
Drop a Factur-X PDF into our free validator for a report on the PDF/A-3
|
|
151
|
+
container, the embedded XML, the official XSD and the EN 16931 business rules.
|
|
152
|
+
It runs entirely in the browser — the invoice never leaves your machine, and
|
|
153
|
+
there is no account to create.
|
|
154
|
+
|
|
155
|
+
- English — https://www.sxnlabs.com/en/factur-x-validator/
|
|
156
|
+
- Français — https://www.sxnlabs.com/verifier-facture-electronique/
|
|
157
|
+
|
|
148
158
|
## Formats
|
|
149
159
|
|
|
150
160
|
### Factur-X (PDF/A-3 + CII)
|
|
@@ -17,12 +17,12 @@ en:
|
|
|
17
17
|
name_missing: "Seller name is required"
|
|
18
18
|
siren_invalid: "Seller SIREN must be 9 digits (Luhn check failed)"
|
|
19
19
|
siret_invalid: "Seller SIRET must be 14 digits (Luhn check failed)"
|
|
20
|
-
vat_number_invalid: "Seller VAT number
|
|
20
|
+
vat_number_invalid: "Seller VAT number is invalid for its country (format, or check key for France)"
|
|
21
21
|
buyer:
|
|
22
22
|
name_missing: "Buyer name is required"
|
|
23
23
|
siren_invalid: "Buyer SIREN must be 9 digits (Luhn check failed)"
|
|
24
24
|
siret_invalid: "Buyer SIRET must be 14 digits (Luhn check failed)"
|
|
25
|
-
vat_number_invalid: "Buyer VAT number
|
|
25
|
+
vat_number_invalid: "Buyer VAT number is invalid for its country (format, or check key for France)"
|
|
26
26
|
line:
|
|
27
27
|
description_missing: "Line %{index}: description is required"
|
|
28
28
|
quantity_invalid: "Line %{index}: quantity must be positive"
|
|
@@ -17,12 +17,12 @@ fr:
|
|
|
17
17
|
name_missing: "Le nom du vendeur est requis"
|
|
18
18
|
siren_invalid: "Le SIREN du vendeur doit comporter 9 chiffres (vérification Luhn échouée)"
|
|
19
19
|
siret_invalid: "Le SIRET du vendeur doit comporter 14 chiffres (vérification Luhn échouée)"
|
|
20
|
-
vat_number_invalid: "Le
|
|
20
|
+
vat_number_invalid: "Le numéro de TVA du vendeur est invalide pour son pays (format, ou clé de contrôle pour la France)"
|
|
21
21
|
buyer:
|
|
22
22
|
name_missing: "Le nom de l'acheteur est requis"
|
|
23
23
|
siren_invalid: "Le SIREN de l'acheteur doit comporter 9 chiffres (vérification Luhn échouée)"
|
|
24
24
|
siret_invalid: "Le SIRET de l'acheteur doit comporter 14 chiffres (vérification Luhn échouée)"
|
|
25
|
-
vat_number_invalid: "Le
|
|
25
|
+
vat_number_invalid: "Le numéro de TVA de l'acheteur est invalide pour son pays (format, ou clé de contrôle pour la France)"
|
|
26
26
|
line:
|
|
27
27
|
description_missing: "Ligne %{index} : la description est requise"
|
|
28
28
|
quantity_invalid: "Ligne %{index} : la quantité doit être positive"
|
|
@@ -116,20 +116,36 @@ module Einvoicing
|
|
|
116
116
|
def self.header_trade_agreement(b, invoice, profile)
|
|
117
117
|
b.tag("ram:ApplicableHeaderTradeAgreement") do
|
|
118
118
|
# BuyerReference must be first in the sequence (EN 16931 BR-10 / XSD order).
|
|
119
|
-
|
|
119
|
+
# Falls back to the invoice number like UBL does: an empty element is
|
|
120
|
+
# worse than either value, and BT-10 is mandatory for Chorus Pro.
|
|
121
|
+
b.text("ram:BuyerReference", invoice.payment_reference || invoice.invoice_number)
|
|
120
122
|
b.tag("ram:SellerTradeParty") { party_xml(b, invoice.seller, profile) }
|
|
121
123
|
b.tag("ram:BuyerTradeParty") { party_xml(b, invoice.buyer, profile) }
|
|
122
124
|
end
|
|
123
125
|
end
|
|
124
126
|
private_class_method :header_trade_agreement
|
|
125
127
|
|
|
128
|
+
# ISO 6523 identifier scheme for a French legal registration (BT-30 /
|
|
129
|
+
# BT-47). The code has to match the *length* of the number it labels:
|
|
130
|
+
# 0002 is SIRENE, which is the 9-digit SIREN, and a 14-digit SIRET
|
|
131
|
+
# announced as 0002 is what a Plateforme Agréée rejects the document for.
|
|
132
|
+
# Chorus Pro predates the codelist and wants the literal word instead.
|
|
133
|
+
SIREN_SCHEME = "0002"
|
|
134
|
+
SIRET_SCHEME = "0009"
|
|
135
|
+
|
|
136
|
+
def self.legal_id_scheme(party, profile)
|
|
137
|
+
return "SIRET" if profile == :chorus_pro && party.siret
|
|
138
|
+
|
|
139
|
+
party.siret ? SIRET_SCHEME : SIREN_SCHEME
|
|
140
|
+
end
|
|
141
|
+
private_class_method :legal_id_scheme
|
|
142
|
+
|
|
126
143
|
def self.party_xml(b, party, profile)
|
|
127
144
|
b.text("ram:Name", party.name)
|
|
128
145
|
legal_id = party.siret || party.siren
|
|
129
146
|
if legal_id
|
|
130
147
|
b.tag("ram:SpecifiedLegalOrganization") do
|
|
131
|
-
|
|
132
|
-
b.text("ram:ID", legal_id, "schemeID" => scheme)
|
|
148
|
+
b.text("ram:ID", legal_id, "schemeID" => legal_id_scheme(party, profile))
|
|
133
149
|
end
|
|
134
150
|
end
|
|
135
151
|
b.tag("ram:PostalTradeAddress") do
|
data/lib/einvoicing/invoice.rb
CHANGED
|
@@ -7,8 +7,8 @@ module Einvoicing
|
|
|
7
7
|
# Core invoice model. All monetary values are in the invoice currency.
|
|
8
8
|
#
|
|
9
9
|
# @example
|
|
10
|
-
# seller = Einvoicing::Party.new(name: "Acme SAS", siren: "
|
|
11
|
-
# buyer = Einvoicing::Party.new(name: "Client SA", siren: "
|
|
10
|
+
# seller = Einvoicing::Party.new(name: "Acme SAS", siren: "123456782", vat_number: "FR11123456782")
|
|
11
|
+
# buyer = Einvoicing::Party.new(name: "Client SA", siren: "987654316")
|
|
12
12
|
# line = Einvoicing::LineItem.new(description: "Consulting", quantity: 1, unit_price: 1000.00)
|
|
13
13
|
#
|
|
14
14
|
# invoice = Einvoicing::Invoice.new(
|
data/lib/einvoicing/party.rb
CHANGED
|
@@ -10,8 +10,8 @@ module Einvoicing
|
|
|
10
10
|
# city: "Paris",
|
|
11
11
|
# postal_code: "75001",
|
|
12
12
|
# country_code: "FR",
|
|
13
|
-
# siren: "
|
|
14
|
-
# vat_number: "
|
|
13
|
+
# siren: "123456782",
|
|
14
|
+
# vat_number: "FR11123456782"
|
|
15
15
|
# )
|
|
16
16
|
Party = Data.define(:name, :street, :city, :postal_code, :country_code,
|
|
17
17
|
:siren, :siret, :vat_number, :email,
|
|
@@ -103,15 +103,38 @@ module Einvoicing
|
|
|
103
103
|
# Validate a VAT number against the format of the country it belongs to.
|
|
104
104
|
# The party's own country wins when known, so a French party still has to
|
|
105
105
|
# carry an FR number; otherwise the number's own prefix decides.
|
|
106
|
-
# @param vat [String] e.g. "
|
|
106
|
+
# @param vat [String] e.g. "FR11123456782", "DE811907980"
|
|
107
107
|
# @param country_code [String, nil] the party's country (BT-40 / BT-55)
|
|
108
108
|
# @return [Boolean]
|
|
109
109
|
def self.valid_vat_number?(vat, country_code: nil)
|
|
110
110
|
str = vat.to_s.gsub(/\s/, "").upcase
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
country = country_code.to_s.upcase
|
|
112
|
+
country = str[0, 2].to_s unless EU_VAT_PATTERNS.key?(country)
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
pattern = EU_VAT_PATTERNS[country]
|
|
115
|
+
return str.match?(NON_EU_VAT_RE) unless pattern
|
|
116
|
+
return false unless str.match?(pattern)
|
|
117
|
+
|
|
118
|
+
country == "FR" ? valid_fr_vat_number?(str) : true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A French VAT number carries a check key over its SIREN, so a typo that
|
|
122
|
+
# keeps the shape still has to be caught: FR83552032534 looks right and
|
|
123
|
+
# is not (the key for that SIREN is 27).
|
|
124
|
+
#
|
|
125
|
+
# The official structure is "FR" + 2 characters + 9 digits, and those two
|
|
126
|
+
# characters may be alphanumeric. Only a numeric key comes from the
|
|
127
|
+
# published formula, so an alphanumeric one is checked on its shape and
|
|
128
|
+
# on its SIREN alone — rejecting it would refuse valid invoices.
|
|
129
|
+
# @param vat [String] already stripped and upcased, matching VAT_RE
|
|
130
|
+
# @return [Boolean]
|
|
131
|
+
def self.valid_fr_vat_number?(vat)
|
|
132
|
+
key = vat[2, 2]
|
|
133
|
+
siren = vat[4, 9]
|
|
134
|
+
return false unless valid_siren?(siren)
|
|
135
|
+
return true unless key.match?(/\A\d{2}\z/)
|
|
136
|
+
|
|
137
|
+
key.to_i == (12 + (3 * (siren.to_i % 97))) % 97
|
|
115
138
|
end
|
|
116
139
|
|
|
117
140
|
# A known French VAT rate, including the DOM and Corsican rates that the
|
data/lib/einvoicing/version.rb
CHANGED
data/lib/einvoicing.rb
CHANGED
|
@@ -37,7 +37,7 @@ end
|
|
|
37
37
|
# SIRET, TVA). Provides a Rails concern for ActiveRecord models.
|
|
38
38
|
#
|
|
39
39
|
# @example Quick start
|
|
40
|
-
# seller = Einvoicing::Party.new(name: "Acme SAS", siren: "356000000", vat_number: "
|
|
40
|
+
# seller = Einvoicing::Party.new(name: "Acme SAS", siren: "356000000", vat_number: "FR39356000000")
|
|
41
41
|
# buyer = Einvoicing::Party.new(name: "Client SA", siren: "552032534")
|
|
42
42
|
# line = Einvoicing::LineItem.new(description: "Consulting", quantity: 1, unit_price: 1000.00)
|
|
43
43
|
#
|