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)


148
149
150
# File 'lib/einvoicing/validators/fr.rb', line 148

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

.valid_iban?(iban) ⇒ Boolean

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

Parameters:

  • iban (String)

Returns:

  • (Boolean)


135
136
137
138
139
140
141
142
143
# File 'lib/einvoicing/validators/fr.rb', line 135

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)


128
129
130
# File 'lib/einvoicing/validators/fr.rb', line 128

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. "FR12123456789", "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
# File 'lib/einvoicing/validators/fr.rb', line 109

def self.valid_vat_number?(vat, country_code: nil)
  str = vat.to_s.gsub(/\s/, "").upcase
  pattern = EU_VAT_PATTERNS[country_code.to_s.upcase] || EU_VAT_PATTERNS[str[0, 2]]
  return str.match?(pattern) if pattern

  str.match?(NON_EU_VAT_RE)
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)


121
122
123
# File 'lib/einvoicing/validators/fr.rb', line 121

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