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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f41193fc67e566fb4f03b91fcbb9666e81540c72411d03535ac58fe92d3573a2
4
- data.tar.gz: 3d36fab9deb25fb3f25cadde7e435c5227a20f37e9011d3c22977659e1058bb2
3
+ metadata.gz: ebdccf05d23870723b8c45634ecfa3f19f587c8e2bf4b3f1e3652b84a2874b13
4
+ data.tar.gz: f329a5f6d4a3c2ba2d7bfce7c935d81964208158f43e4760c8723d1606a3f9c5
5
5
  SHA512:
6
- metadata.gz: 98d84a972f5dc4eaa604ca1360d741a916ceac43f54ec824f6dfd25143706c793cb27c5376d2e959cf919e61805fef9eb64066d839a3da3c875a638db9927261
7
- data.tar.gz: fa99bcf35ebdbf90bb4ede4a031406201899fc4c288093a634df8126b0b738830340ba1894027b932c84397c232c3f4b8dab7f0cbd208a7a3148fcff1c499cc5
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: "FR83552032534"
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 format is invalid for its country prefix"
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 format is invalid for its country prefix"
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 format du numéro de TVA du vendeur est invalide pour son préfixe pays"
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 format du numéro de TVA de l'acheteur est invalide pour son préfixe pays"
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
- b.text("ram:BuyerReference", invoice.payment_reference || "")
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
- scheme = (profile == :chorus_pro && party.siret) ? "SIRET" : "0002"
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
@@ -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: "123456789", vat_number: "FR12123456789")
11
- # buyer = Einvoicing::Party.new(name: "Client SA", siren: "987654321")
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(
@@ -10,8 +10,8 @@ module Einvoicing
10
10
  # city: "Paris",
11
11
  # postal_code: "75001",
12
12
  # country_code: "FR",
13
- # siren: "123456789",
14
- # vat_number: "FR12123456789"
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. "FR12123456789", "DE811907980"
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
- pattern = EU_VAT_PATTERNS[country_code.to_s.upcase] || EU_VAT_PATTERNS[str[0, 2]]
112
- return str.match?(pattern) if pattern
111
+ country = country_code.to_s.upcase
112
+ country = str[0, 2].to_s unless EU_VAT_PATTERNS.key?(country)
113
113
 
114
- str.match?(NON_EU_VAT_RE)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Einvoicing
4
- VERSION = "0.9.0"
4
+ VERSION = "0.9.2"
5
5
  end
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: "FR83356000000")
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
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: einvoicing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Le Ray