Module: Einvoicing::Validators::FR

Defined in:
lib/einvoicing/validators/fr.rb

Overview

French invoice validator. Checks SIREN, SIRET, TVA (VAT) number format and Luhn checksum, plus mandatory invoice fields for French B2B compliance.

Examples:

errors = Einvoicing::Validators::FR.validate(invoice)
errors.empty? # => true if valid

Einvoicing::Validators::FR.validate!(invoice) # raises on failure

Constant Summary collapse

SIREN_RE =
/\A\d{9}\z/
SIRET_RE =
/\A\d{14}\z/
VAT_RE =

FR VAT: "FR" + 2 alphanumeric chars + 9-digit SIREN

/\AFR[A-Z0-9]{2}\d{9}\z/
INV_NUM_RE =
/\A[\w\-\/]{1,35}\z/
EU_VAT_PATTERNS =

An invoice to a company in another member state carries that country's VAT number (BT-48), not a French one. Rejecting it broke every intra-Community and reverse-charge invoice.

{
  "AT" => /\AATU\d{8}\z/,                 "BE" => /\ABE[01]\d{9}\z/,
  "BG" => /\ABG\d{9,10}\z/,               "CY" => /\ACY\d{8}[A-Z]\z/,
  "CZ" => /\ACZ\d{8,10}\z/,               "DE" => /\ADE\d{9}\z/,
  "DK" => /\ADK\d{8}\z/,                  "EE" => /\AEE\d{9}\z/,
  "EL" => /\AEL\d{9}\z/,                  "ES" => /\AES[A-Z0-9]\d{7}[A-Z0-9]\z/,
  "FI" => /\AFI\d{8}\z/,                  "FR" => VAT_RE,
  "HR" => /\AHR\d{11}\z/,                 "HU" => /\AHU\d{8}\z/,
  "IE" => /\AIE(\d{7}[A-Z]{1,2}|\d[A-Z0-9+*]\d{5}[A-Z])\z/,
  "IT" => /\AIT\d{11}\z/,                 "LT" => /\ALT(\d{9}|\d{12})\z/,
  "LU" => /\ALU\d{8}\z/,                  "LV" => /\ALV\d{11}\z/,
  "MT" => /\AMT\d{8}\z/,                  "NL" => /\ANL\d{9}B\d{2}\z/,
  "PL" => /\APL\d{10}\z/,                 "PT" => /\APT\d{9}\z/,
  "RO" => /\ARO\d{2,10}\z/,               "SE" => /\ASE\d{12}\z/,
  "SI" => /\ASI\d{8}\z/,                  "SK" => /\ASK\d{10}\z/,
  # Northern Ireland keeps an EU VAT identifier under the Windsor Framework.
  "XI" => /\AXI(\d{9}|\d{12}|(GD|HA)\d{3})\z/
}.freeze
NON_EU_VAT_RE =

Fallback for non-EU counterparties: 2-letter country prefix + 2 to 15 alphanumeric characters. Enough to catch a typo, not a customs ruling.

/\A[A-Z]{2}[A-Z0-9]{2,15}\z/
VAT_RATES =

France (metropolitan): 20 %, 10 %, 5.5 %, 2.1 %. DOM (Guadeloupe, Martinique, Réunion): 8.5 %, 2.1 %, 1.75 %, 1.05 %. Corsica: 20 %, 13 %, 10 %, 2.1 %, 0.9 %.

[
  "0", "0.009", "0.0105", "0.0175", "0.021",
  "0.055", "0.085", "0.10", "0.13", "0.20"
].map { |r| BigDecimal(r) }.freeze
IBAN_RE =

IBAN: country code (2 alpha) + 2 check digits + BBAN (11-30 alphanumeric) = 15-34 total

/\A[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}\z/
BIC_RE =

BIC: 4 alpha (institution) + 2 alpha (country) + 2 alphanumeric (location) + optional 3 alphanumeric (branch)

/\A[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?\z/

Class Method Summary collapse

Class Method Details

.valid_bic?(bic) ⇒ Boolean

Validate a BIC (ISO 9362) — 8 or 11 chars.

Parameters:

  • bic (String)

Returns:

  • (Boolean)


171
172
173
# File 'lib/einvoicing/validators/fr.rb', line 171

def self.valid_bic?(bic)
  bic.to_s.match?(BIC_RE)
end

.valid_fr_vat_number?(vat) ⇒ Boolean

A French VAT number carries a check key over its SIREN, so a typo that keeps the shape still has to be caught: FR83552032534 looks right and is not (the key for that SIREN is 27).

The official structure is "FR" + 2 characters + 9 digits, and those two characters may be alphanumeric. Only a numeric key comes from the published formula, so an alphanumeric one is checked on its shape and on its SIREN alone — rejecting it would refuse valid invoices.

Parameters:

  • vat (String)

    already stripped and upcased, matching VAT_RE

Returns:

  • (Boolean)


131
132
133
134
135
136
137
138
# File 'lib/einvoicing/validators/fr.rb', line 131

def self.valid_fr_vat_number?(vat)
  key   = vat[2, 2]
  siren = vat[4, 9]
  return false unless valid_siren?(siren)
  return true unless key.match?(/\A\d{2}\z/)

  key.to_i == (12 + (3 * (siren.to_i % 97))) % 97
end

.valid_iban?(iban) ⇒ Boolean

Validate an IBAN (ISO 13616 format + mod-97 checksum).

Parameters:

  • iban (String)

Returns:

  • (Boolean)


158
159
160
161
162
163
164
165
166
# File 'lib/einvoicing/validators/fr.rb', line 158

def self.valid_iban?(iban)
  str = iban.to_s.gsub(/\s/, "").upcase
  return false unless str.match?(IBAN_RE)

  # Move first 4 chars to end, replace each letter with its numeric value (A=10..Z=35)
  rearranged = str[4..] + str[0..3]
  numeric = rearranged.chars.map { |c| c =~ /[A-Z]/ ? (c.ord - 55).to_s : c }.join
  numeric.to_i % 97 == 1
end

.valid_invoice_number?(number) ⇒ Boolean

Validate an invoice number format (alphanumeric, dashes, slashes, 1-35 chars).

Parameters:

  • number (String)

Returns:

  • (Boolean)


151
152
153
# File 'lib/einvoicing/validators/fr.rb', line 151

def self.valid_invoice_number?(number)
  number.to_s.match?(INV_NUM_RE)
end

.valid_siren?(siren) ⇒ Boolean

Validate a single SIREN number.

Parameters:

  • siren (String)

Returns:

  • (Boolean)


88
89
90
91
92
# File 'lib/einvoicing/validators/fr.rb', line 88

def self.valid_siren?(siren)
  return false unless siren.to_s.match?(SIREN_RE)

  Base.luhn_valid?(siren.to_s)
end

.valid_siret?(siret) ⇒ Boolean

Validate a single SIRET number.

Parameters:

  • siret (String)

Returns:

  • (Boolean)


97
98
99
100
101
# File 'lib/einvoicing/validators/fr.rb', line 97

def self.valid_siret?(siret)
  return false unless siret.to_s.match?(SIRET_RE)

  Base.luhn_valid?(siret.to_s)
end

.valid_vat_number?(vat, country_code: nil) ⇒ Boolean

Validate a VAT number against the format of the country it belongs to. The party's own country wins when known, so a French party still has to carry an FR number; otherwise the number's own prefix decides.

Parameters:

  • vat (String)

    e.g. "FR11123456782", "DE811907980"

  • country_code (String, nil) (defaults to: nil)

    the party's country (BT-40 / BT-55)

Returns:

  • (Boolean)


109
110
111
112
113
114
115
116
117
118
119
# File 'lib/einvoicing/validators/fr.rb', line 109

def self.valid_vat_number?(vat, country_code: nil)
  str = vat.to_s.gsub(/\s/, "").upcase
  country = country_code.to_s.upcase
  country = str[0, 2].to_s unless EU_VAT_PATTERNS.key?(country)

  pattern = EU_VAT_PATTERNS[country]
  return str.match?(NON_EU_VAT_RE) unless pattern
  return false unless str.match?(pattern)

  country == "FR" ? valid_fr_vat_number?(str) : true
end

.valid_vat_rate?(rate) ⇒ Boolean

A known French VAT rate, including the DOM and Corsican rates that the "20 / 10 / 5.5 / 0" shortlist leaves out.

Parameters:

  • rate (Numeric)

    e.g. 0.021

Returns:

  • (Boolean)


144
145
146
# File 'lib/einvoicing/validators/fr.rb', line 144

def self.valid_vat_rate?(rate)
  VAT_RATES.include?(BigDecimal(rate.to_s).round(4))
end

.validate(invoice) ⇒ Array<Hash>

Returns list of error hashes ({ field:, error:, message: }); empty if valid.

Parameters:

Returns:

  • (Array<Hash>)

    list of error hashes ({ field:, error:, message: }); empty if valid



64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/einvoicing/validators/fr.rb', line 64

def self.validate(invoice)
  [
    *validate_invoice_fields(invoice),
    *validate_party(invoice.seller, :seller),
    *validate_party(invoice.buyer,  :buyer),
    *validate_lines(invoice.lines),
    *validate_allowances_charges(invoice.allowances, :allowance),
    *validate_allowances_charges(invoice.charges, :charge),
    *validate_tax_breakdown(invoice.tax_breakdown),
    *validate_tax_currency(invoice)
  ]
end

.validate!(invoice) ⇒ Object

Raises:



78
79
80
81
82
83
# File 'lib/einvoicing/validators/fr.rb', line 78

def self.validate!(invoice)
  errors = validate(invoice)
  raise ValidationError, errors.map { |e| e[:message] }.join("; ") unless errors.empty?

  true
end