einvoicing 0.9.0 → 0.9.1

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: a112029dd945f7dfbdc67dcf4ee76420d1708284a7bb18d6c04a2c32de2a7015
4
+ data.tar.gz: 6f4a1302247e1b572b9713b51d4605d4e354c9000159d5755e36c1331ba70090
5
5
  SHA512:
6
- metadata.gz: 98d84a972f5dc4eaa604ca1360d741a916ceac43f54ec824f6dfd25143706c793cb27c5376d2e959cf919e61805fef9eb64066d839a3da3c875a638db9927261
7
- data.tar.gz: fa99bcf35ebdbf90bb4ede4a031406201899fc4c288093a634df8126b0b738830340ba1894027b932c84397c232c3f4b8dab7f0cbd208a7a3148fcff1c499cc5
6
+ metadata.gz: ecf8f50fc15ce50e55506a7c74ab492d0a2b23987d29087a6555c229d62b9c03848d459b22da07e1428fd79ab0445da5726670f324823af36f611d61bdd0fdad
7
+ data.tar.gz: e7867f59abf64d56f82fe5903d5e76994986ee3e64445a49897fd25929e19b5afc245905955b749f06ecff83e893f9bfbfa676079a5ce7622f85441106ebf4db
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.1] - 2026-08-06
11
+
12
+ ### Added
13
+ - Link to the online Factur-X validator in the README — runs entirely client-side with no upload required
14
+
15
+ ### Fixed
16
+ - 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
17
+ - 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
18
+ - 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
19
+ - 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
20
+
10
21
  ## [0.9.0] - 2026-08-05
11
22
 
12
23
  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,7 +116,9 @@ 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
@@ -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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Le Ray