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.
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
-
.valid_bic?(bic) ⇒ Boolean
Validate a BIC (ISO 9362) — 8 or 11 chars.
-
.valid_iban?(iban) ⇒ Boolean
Validate an IBAN (ISO 13616 format + mod-97 checksum).
-
.valid_invoice_number?(number) ⇒ Boolean
Validate an invoice number format (alphanumeric, dashes, slashes, 1-35 chars).
-
.valid_siren?(siren) ⇒ Boolean
Validate a single SIREN number.
-
.valid_siret?(siret) ⇒ Boolean
Validate a single SIRET number.
-
.valid_vat_number?(vat, country_code: nil) ⇒ Boolean
Validate a VAT number against the format of the country it belongs to.
-
.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.
-
.validate(invoice) ⇒ Array<Hash>
List of error hashes ({ field:, error:, message: }); empty if valid.
- .validate!(invoice) ⇒ Object
Class Method Details
.valid_bic?(bic) ⇒ Boolean
Validate a BIC (ISO 9362) — 8 or 11 chars.
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).
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).
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.
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.
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.
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.
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.
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
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 |