einvoicing 0.2.0 → 0.3.0
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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +277 -0
- data/config/locales/einvoicing.en.yml +27 -0
- data/config/locales/einvoicing.fr.yml +27 -0
- data/lib/einvoicing/data/srgb.icc +0 -0
- data/lib/einvoicing/formats/cii.rb +218 -0
- data/lib/einvoicing/formats/facturx.rb +195 -0
- data/lib/einvoicing/formats/ubl.rb +226 -0
- data/lib/einvoicing/i18n.rb +22 -0
- data/lib/einvoicing/invoice.rb +99 -0
- data/lib/einvoicing/invoiceable.rb +120 -0
- data/lib/einvoicing/line_item.rb +54 -0
- data/lib/einvoicing/party.rb +29 -0
- data/lib/einvoicing/ppf/client.rb +117 -0
- data/lib/einvoicing/ppf/errors.rb +12 -0
- data/lib/einvoicing/ppf/invoice_adapter.rb +61 -0
- data/lib/einvoicing/ppf/submitter.rb +32 -0
- data/lib/einvoicing/ppf.rb +6 -0
- data/lib/einvoicing/rails/concern.rb +4 -0
- data/lib/einvoicing/rails/engine.rb +21 -0
- data/lib/einvoicing/tax.rb +38 -0
- data/lib/einvoicing/validators/base.rb +52 -0
- data/lib/einvoicing/validators/fr.rb +191 -0
- data/lib/einvoicing/version.rb +1 -1
- data/lib/einvoicing/xml_builder.rb +67 -0
- data/lib/einvoicing.rb +45 -5
- metadata +135 -8
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "bigdecimal/util"
|
|
5
|
+
|
|
6
|
+
module Einvoicing
|
|
7
|
+
# A single line on an invoice.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# Einvoicing::LineItem.new(
|
|
11
|
+
# description: "Software consulting",
|
|
12
|
+
# quantity: 5,
|
|
13
|
+
# unit_price: 150.00,
|
|
14
|
+
# vat_rate: 0.20
|
|
15
|
+
# )
|
|
16
|
+
LineItem = Data.define(:description, :quantity, :unit_price, :vat_rate, :unit, :category) do
|
|
17
|
+
def initialize(description:, quantity:, unit_price:, vat_rate: 0.20, unit: "C62", category: nil)
|
|
18
|
+
super(
|
|
19
|
+
description: description,
|
|
20
|
+
quantity: BigDecimal(quantity.to_s),
|
|
21
|
+
unit_price: BigDecimal(unit_price.to_s),
|
|
22
|
+
vat_rate: vat_rate,
|
|
23
|
+
unit: unit,
|
|
24
|
+
category: category
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Net line total (excluding VAT).
|
|
29
|
+
def net_amount
|
|
30
|
+
(quantity * unit_price).round(2, :half_up)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# VAT amount for this line.
|
|
34
|
+
def vat_amount
|
|
35
|
+
(net_amount * BigDecimal(vat_rate.to_s)).round(2, :half_up)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Gross line total (including VAT).
|
|
39
|
+
def gross_amount
|
|
40
|
+
(net_amount + vat_amount).round(2, :half_up)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def vat_rate_percent
|
|
44
|
+
return BigDecimal("0") if category == :reverse_charge
|
|
45
|
+
|
|
46
|
+
(BigDecimal(vat_rate.to_s) * 100).round(2)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# CII/UBL tax category code — delegates to shared Tax logic.
|
|
50
|
+
def tax_category_code
|
|
51
|
+
Tax.category_code_for(rate: vat_rate, category: category)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Einvoicing
|
|
4
|
+
# Represents a seller or buyer on an invoice.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# Einvoicing::Party.new(
|
|
8
|
+
# name: "Acme SAS",
|
|
9
|
+
# street: "1 rue de la Paix",
|
|
10
|
+
# city: "Paris",
|
|
11
|
+
# postal_code: "75001",
|
|
12
|
+
# country_code: "FR",
|
|
13
|
+
# siren: "123456789",
|
|
14
|
+
# vat_number: "FR12123456789"
|
|
15
|
+
# )
|
|
16
|
+
Party = Data.define(:name, :street, :city, :postal_code, :country_code,
|
|
17
|
+
:siren, :siret, :vat_number, :email) do
|
|
18
|
+
def initialize(name:, street: nil, city: nil, postal_code: nil,
|
|
19
|
+
country_code: "FR", siren: nil, siret: nil,
|
|
20
|
+
vat_number: nil, email: nil)
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The 9-digit SIREN derived from SIRET if siren not provided directly.
|
|
25
|
+
def siren_number
|
|
26
|
+
siren || (siret && siret[0, 9])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Einvoicing
|
|
7
|
+
module PPF
|
|
8
|
+
class Client
|
|
9
|
+
SANDBOX_OAUTH_URL = "https://sandbox-oauth.piste.gouv.fr"
|
|
10
|
+
SANDBOX_API_URL = "https://sandbox-api.piste.gouv.fr/cpro/factures"
|
|
11
|
+
PROD_OAUTH_URL = "https://oauth.piste.gouv.fr"
|
|
12
|
+
PROD_API_URL = "https://api.piste.gouv.fr/cpro/factures"
|
|
13
|
+
|
|
14
|
+
attr_reader :sandbox
|
|
15
|
+
|
|
16
|
+
def initialize(client_id:, client_secret:, sandbox: true)
|
|
17
|
+
@client_id = client_id
|
|
18
|
+
@client_secret = client_secret
|
|
19
|
+
@sandbox = sandbox
|
|
20
|
+
@token = nil
|
|
21
|
+
@token_expires_at = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns current access token, refreshing if expired.
|
|
25
|
+
def access_token
|
|
26
|
+
refresh_token! if token_expired?
|
|
27
|
+
@token
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# POST /v1/rechercher/structure — find structure by SIRET, returns idStructureCPP.
|
|
31
|
+
def find_structure(siret:)
|
|
32
|
+
post("/v1/rechercher/structure", { siret: siret })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# POST /v1/consulter/structure — get mandatory params (engagement, service codes).
|
|
36
|
+
def get_structure(id_structure_cpp:)
|
|
37
|
+
post("/v1/consulter/structure", { idStructureCPP: id_structure_cpp })
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# POST /v1/rechercher/service/structure — list active services for a structure.
|
|
41
|
+
def list_services(id_structure_cpp:, page: 1)
|
|
42
|
+
post("/v1/rechercher/service/structure", {
|
|
43
|
+
idStructureCPP: id_structure_cpp,
|
|
44
|
+
pageResultat: page
|
|
45
|
+
})
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# POST /v1/soumettre/factures — submit an invoice.
|
|
49
|
+
# facture_hash: Hash matching the Chorus Pro invoice schema.
|
|
50
|
+
def submit_invoice(facture_hash)
|
|
51
|
+
post("/v1/soumettre/factures", facture_hash)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def base_url
|
|
57
|
+
sandbox ? SANDBOX_API_URL : PROD_API_URL
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def oauth_url
|
|
61
|
+
sandbox ? SANDBOX_OAUTH_URL : PROD_OAUTH_URL
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def token_expired?
|
|
65
|
+
@token.nil? || @token_expires_at.nil? || Time.now >= @token_expires_at
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def refresh_token!
|
|
69
|
+
uri = URI("#{oauth_url}/api/oauth/token")
|
|
70
|
+
req = Net::HTTP::Post.new(uri)
|
|
71
|
+
req.set_form_data(
|
|
72
|
+
grant_type: "client_credentials",
|
|
73
|
+
client_id: @client_id,
|
|
74
|
+
client_secret: @client_secret,
|
|
75
|
+
scope: "openid"
|
|
76
|
+
)
|
|
77
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
|
|
78
|
+
raise AuthenticationError, "OAuth token request failed: #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
79
|
+
|
|
80
|
+
data = JSON.parse(res.body)
|
|
81
|
+
@token = data["access_token"]
|
|
82
|
+
@token_expires_at = Time.now + data.fetch("expires_in", 3600).to_i - 60
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def post(path, body)
|
|
86
|
+
uri = URI("#{base_url}#{path}")
|
|
87
|
+
req = Net::HTTP::Post.new(uri)
|
|
88
|
+
req["Authorization"] = "Bearer #{access_token}"
|
|
89
|
+
req["Content-Type"] = "application/json;charset=UTF-8"
|
|
90
|
+
req["Accept"] = "application/json"
|
|
91
|
+
req.body = body.to_json
|
|
92
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
|
|
93
|
+
handle_response(res)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def handle_response(res)
|
|
97
|
+
body = begin
|
|
98
|
+
JSON.parse(res.body)
|
|
99
|
+
rescue StandardError
|
|
100
|
+
res.body
|
|
101
|
+
end
|
|
102
|
+
case res
|
|
103
|
+
when Net::HTTPSuccess
|
|
104
|
+
body
|
|
105
|
+
when Net::HTTPUnauthorized
|
|
106
|
+
raise AuthenticationError, "Unauthorized: #{body}"
|
|
107
|
+
when Net::HTTPForbidden
|
|
108
|
+
raise AuthorizationError, "Forbidden: #{body}"
|
|
109
|
+
when Net::HTTPNotFound
|
|
110
|
+
raise NotFoundError, "Not found: #{body}"
|
|
111
|
+
else
|
|
112
|
+
raise APIError, "API error #{res.code}: #{body}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Einvoicing
|
|
4
|
+
module PPF
|
|
5
|
+
Error = Class.new(StandardError)
|
|
6
|
+
AuthenticationError = Class.new(Error)
|
|
7
|
+
AuthorizationError = Class.new(Error)
|
|
8
|
+
NotFoundError = Class.new(Error)
|
|
9
|
+
APIError = Class.new(Error)
|
|
10
|
+
ValidationError = Class.new(Error)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Einvoicing
|
|
4
|
+
module PPF
|
|
5
|
+
class InvoiceAdapter
|
|
6
|
+
# Converts an Einvoicing::Invoice to a Chorus Pro SoumettreFacture payload.
|
|
7
|
+
#
|
|
8
|
+
# @param invoice [Einvoicing::Invoice]
|
|
9
|
+
# @param id_structure_cpp [Integer] from find_structure()
|
|
10
|
+
# @param code_service [String, nil] from list_services() — optional
|
|
11
|
+
# @param numero_engagement [String, nil] buyer PO/engagement number — optional for B2B
|
|
12
|
+
def self.to_chorus_payload(invoice, id_structure_cpp:, code_service: nil, numero_engagement: nil)
|
|
13
|
+
{
|
|
14
|
+
idStructureCPP: id_structure_cpp,
|
|
15
|
+
codeService: code_service,
|
|
16
|
+
numeroEngagement: numero_engagement,
|
|
17
|
+
cadreFacturation: {
|
|
18
|
+
codeCadreFacturation: "FACTURE_FOURNISSEUR",
|
|
19
|
+
codeServiceValideur: nil
|
|
20
|
+
},
|
|
21
|
+
identifiantFactureFournisseur: invoice.invoice_number,
|
|
22
|
+
dateFacture: invoice.issue_date.strftime("%Y-%m-%dT00:00:00.000+01:00"),
|
|
23
|
+
dateEcheancePaiement: invoice.due_date&.strftime("%Y-%m-%dT00:00:00.000+01:00"),
|
|
24
|
+
montantHT: invoice.net_total.to_f,
|
|
25
|
+
montantTVA: invoice.tax_total.to_f,
|
|
26
|
+
montantTTC: invoice.gross_total.to_f,
|
|
27
|
+
devise: invoice.currency || "EUR",
|
|
28
|
+
siretFournisseur: invoice.seller.siret,
|
|
29
|
+
siretDestinataire: invoice.buyer.siret,
|
|
30
|
+
typeFacture: "FACTURE",
|
|
31
|
+
lignesPoste: invoice.lines.map.with_index(1) { |line, i| line_to_chorus(line, i) },
|
|
32
|
+
modePaiement: chorus_payment_mode(invoice)
|
|
33
|
+
}.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private_class_method def self.line_to_chorus(line, index)
|
|
37
|
+
{
|
|
38
|
+
numeroLigne: index,
|
|
39
|
+
designation: line.description,
|
|
40
|
+
quantite: line.quantity.to_f,
|
|
41
|
+
unite: "U",
|
|
42
|
+
prixUnitaireHT: line.unit_price.to_f,
|
|
43
|
+
montantHT: line.net_amount.to_f,
|
|
44
|
+
tauxTVA: (line.vat_rate * 100).to_f,
|
|
45
|
+
montantTVA: (line.net_amount * line.vat_rate).to_f.round(2)
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private_class_method def self.chorus_payment_mode(invoice)
|
|
50
|
+
return nil unless invoice.respond_to?(:payment_means_code) && invoice.payment_means_code
|
|
51
|
+
|
|
52
|
+
code_map = { 30 => "VIREMENT", 42 => "VIREMENT", 58 => "VIREMENT" }
|
|
53
|
+
mode = code_map[invoice.payment_means_code] || "VIREMENT"
|
|
54
|
+
result = { modePaiement: mode }
|
|
55
|
+
result[:iban] = invoice.iban if invoice.respond_to?(:iban) && invoice.iban
|
|
56
|
+
result[:bic] = invoice.bic if invoice.respond_to?(:bic) && invoice.bic
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Einvoicing
|
|
4
|
+
module PPF
|
|
5
|
+
class Submitter
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Submit an invoice to Chorus Pro / PPF.
|
|
11
|
+
# Returns the submission result hash from the API.
|
|
12
|
+
#
|
|
13
|
+
# @param invoice [Einvoicing::Invoice]
|
|
14
|
+
# @param code_service [String, nil] optional service code from list_services
|
|
15
|
+
# @param numero_engagement [String, nil] optional engagement number
|
|
16
|
+
def submit(invoice, code_service: nil, numero_engagement: nil)
|
|
17
|
+
structure = @client.find_structure(siret: invoice.buyer.siret)
|
|
18
|
+
id_structure = structure["idStructureCPP"] || structure.dig("parametres", "idStructureCPP")
|
|
19
|
+
raise ValidationError, "Buyer SIRET #{invoice.buyer.siret} not found in Chorus Pro" unless id_structure
|
|
20
|
+
|
|
21
|
+
payload = InvoiceAdapter.to_chorus_payload(
|
|
22
|
+
invoice,
|
|
23
|
+
id_structure_cpp: id_structure,
|
|
24
|
+
code_service: code_service,
|
|
25
|
+
numero_engagement: numero_engagement
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@client.submit_invoice(payload)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Einvoicing
|
|
4
|
+
module Rails
|
|
5
|
+
# Rails engine entry point. Automatically extends ActiveRecord::Base with
|
|
6
|
+
# the Invoiceable concern when loaded inside a Rails application.
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace Einvoicing
|
|
9
|
+
|
|
10
|
+
initializer "einvoicing.i18n" do
|
|
11
|
+
config.i18n.load_path += Dir[File.expand_path("../../../config/locales/*.yml", __dir__)]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
initializer "einvoicing.active_record" do
|
|
15
|
+
ActiveSupport.on_load(:active_record) do
|
|
16
|
+
# Models opt-in via `include Einvoicing::Invoiceable`
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "bigdecimal/util"
|
|
5
|
+
|
|
6
|
+
module Einvoicing
|
|
7
|
+
# VAT breakdown entry for a single tax rate.
|
|
8
|
+
Tax = Data.define(:rate, :taxable_amount, :tax_amount, :category) do
|
|
9
|
+
# @param rate [Numeric] e.g. 0.20 for 20% VAT; 0 for zero-rated or reverse charge
|
|
10
|
+
# @param taxable_amount [Numeric] net amount subject to this rate
|
|
11
|
+
# @param tax_amount [Numeric] VAT amount for this rate
|
|
12
|
+
# @param category [Symbol, nil] nil for standard/zero, :reverse_charge for AE
|
|
13
|
+
def initialize(rate:, taxable_amount:, tax_amount:, category: nil)
|
|
14
|
+
raise ArgumentError, "rate must be >= 0, got #{rate}" if rate.to_f.negative?
|
|
15
|
+
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Shared lookup used by both Tax and LineItem.
|
|
20
|
+
def self.category_code_for(rate:, category: nil)
|
|
21
|
+
if rate.to_f == 0.0
|
|
22
|
+
category == :reverse_charge ? "AE" : "Z"
|
|
23
|
+
else
|
|
24
|
+
"S"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def category_code
|
|
29
|
+
Tax.category_code_for(rate: rate, category: category)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def rate_percent
|
|
33
|
+
return BigDecimal("0") if category == :reverse_charge
|
|
34
|
+
|
|
35
|
+
(BigDecimal(rate.to_s) * 100).round(2)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Einvoicing
|
|
4
|
+
module Validators
|
|
5
|
+
# Shared validation helpers for country-specific validators.
|
|
6
|
+
# Each validator exposes a `.validate(invoice)` class method that returns
|
|
7
|
+
# an array of error hashes (empty = valid).
|
|
8
|
+
# Each error hash has the shape:
|
|
9
|
+
# { field: Symbol, error: Symbol, message: String }
|
|
10
|
+
module Base
|
|
11
|
+
def self.included(base)
|
|
12
|
+
base.extend(ClassMethods)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module ClassMethods
|
|
16
|
+
# Validate an invoice and raise if any errors exist.
|
|
17
|
+
def validate!(invoice)
|
|
18
|
+
errors = validate(invoice)
|
|
19
|
+
raise ValidationError, errors.map { |e| e[:message] }.join("; ") unless errors.empty?
|
|
20
|
+
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Luhn algorithm for SIREN (9 digits) and SIRET (14 digits).
|
|
26
|
+
# Returns true if the number passes the Luhn check.
|
|
27
|
+
def self.luhn_valid?(number_string)
|
|
28
|
+
digits = number_string.chars.map(&:to_i)
|
|
29
|
+
sum = 0
|
|
30
|
+
digits.reverse.each_with_index do |d, i|
|
|
31
|
+
d = i.odd? ? d * 2 : d
|
|
32
|
+
d -= 9 if d > 9
|
|
33
|
+
sum += d
|
|
34
|
+
end
|
|
35
|
+
(sum % 10).zero?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Presence check — returns an error hash or nil.
|
|
39
|
+
def self.presence(value, field, message, error: :blank)
|
|
40
|
+
{ field: field, error: error, message: message } if value.nil? || value.to_s.strip.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Format check via regex — returns an error hash or nil.
|
|
44
|
+
def self.format(value, field, pattern, message)
|
|
45
|
+
{ field: field, error: :invalid, message: message } unless value.to_s.match?(pattern)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Raised when `.validate!` finds errors.
|
|
50
|
+
class ValidationError < StandardError; end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Einvoicing
|
|
4
|
+
module Validators
|
|
5
|
+
# French invoice validator.
|
|
6
|
+
# Checks SIREN, SIRET, TVA (VAT) number format and Luhn checksum, plus
|
|
7
|
+
# mandatory invoice fields for French B2B compliance.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# errors = Einvoicing::Validators::FR.validate(invoice)
|
|
11
|
+
# errors.empty? # => true if valid
|
|
12
|
+
#
|
|
13
|
+
# Einvoicing::Validators::FR.validate!(invoice) # raises on failure
|
|
14
|
+
module FR
|
|
15
|
+
SIREN_RE = /\A\d{9}\z/
|
|
16
|
+
SIRET_RE = /\A\d{14}\z/
|
|
17
|
+
# FR VAT: "FR" + 2 alphanumeric chars + 9-digit SIREN
|
|
18
|
+
VAT_RE = /\AFR[A-Z0-9]{2}\d{9}\z/
|
|
19
|
+
INV_NUM_RE = /\A[\w\-\/]{1,35}\z/
|
|
20
|
+
# IBAN: country code (2 alpha) + 2 check digits + BBAN (11-30 alphanumeric) = 15-34 total
|
|
21
|
+
IBAN_RE = /\A[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}\z/
|
|
22
|
+
# BIC: 4 alpha (institution) + 2 alpha (country) + 2 alphanumeric (location) + optional 3 alphanumeric (branch)
|
|
23
|
+
BIC_RE = /\A[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?\z/
|
|
24
|
+
|
|
25
|
+
# @param invoice [Einvoicing::Invoice]
|
|
26
|
+
# @return [Array<Hash>] list of error hashes ({ field:, error:, message: }); empty if valid
|
|
27
|
+
def self.validate(invoice)
|
|
28
|
+
[
|
|
29
|
+
*validate_invoice_fields(invoice),
|
|
30
|
+
*validate_party(invoice.seller, :seller),
|
|
31
|
+
*validate_party(invoice.buyer, :buyer),
|
|
32
|
+
*validate_lines(invoice.lines)
|
|
33
|
+
]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @raise [Einvoicing::Validators::ValidationError] if invalid
|
|
37
|
+
def self.validate!(invoice)
|
|
38
|
+
errors = validate(invoice)
|
|
39
|
+
raise ValidationError, errors.map { |e| e[:message] }.join("; ") unless errors.empty?
|
|
40
|
+
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Validate a single SIREN number.
|
|
45
|
+
# @param siren [String]
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def self.valid_siren?(siren)
|
|
48
|
+
return false unless siren.to_s.match?(SIREN_RE)
|
|
49
|
+
|
|
50
|
+
Base.luhn_valid?(siren.to_s)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Validate a single SIRET number.
|
|
54
|
+
# @param siret [String]
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def self.valid_siret?(siret)
|
|
57
|
+
return false unless siret.to_s.match?(SIRET_RE)
|
|
58
|
+
|
|
59
|
+
Base.luhn_valid?(siret.to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Validate a French VAT number format.
|
|
63
|
+
# @param vat [String] e.g. "FR12123456789"
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def self.valid_vat_number?(vat)
|
|
66
|
+
vat.to_s.match?(VAT_RE)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Validate an invoice number format (alphanumeric, dashes, slashes, 1-35 chars).
|
|
70
|
+
# @param number [String]
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def self.valid_invoice_number?(number)
|
|
73
|
+
number.to_s.match?(INV_NUM_RE)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Validate an IBAN (ISO 13616 format + mod-97 checksum).
|
|
77
|
+
# @param iban [String]
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
def self.valid_iban?(iban)
|
|
80
|
+
str = iban.to_s.gsub(/\s/, "").upcase
|
|
81
|
+
return false unless str.match?(IBAN_RE)
|
|
82
|
+
|
|
83
|
+
# Move first 4 chars to end, replace each letter with its numeric value (A=10..Z=35)
|
|
84
|
+
rearranged = str[4..] + str[0..3]
|
|
85
|
+
numeric = rearranged.chars.map { |c| c =~ /[A-Z]/ ? (c.ord - 55).to_s : c }.join
|
|
86
|
+
numeric.to_i % 97 == 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Validate a BIC (ISO 9362) — 8 or 11 chars.
|
|
90
|
+
# @param bic [String]
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def self.valid_bic?(bic)
|
|
93
|
+
bic.to_s.match?(BIC_RE)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# -- Private helpers ---------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def self.validate_invoice_fields(invoice)
|
|
99
|
+
errors = [
|
|
100
|
+
Base.presence(invoice.invoice_number, :invoice_number,
|
|
101
|
+
Einvoicing::I18n.t("errors.invoice.number_missing"),
|
|
102
|
+
error: :number_missing),
|
|
103
|
+
Base.presence(invoice.issue_date, :issue_date,
|
|
104
|
+
Einvoicing::I18n.t("errors.invoice.issue_date_missing"),
|
|
105
|
+
error: :issue_date_missing),
|
|
106
|
+
Base.presence(invoice.currency, :currency,
|
|
107
|
+
Einvoicing::I18n.t("errors.invoice.currency_missing"),
|
|
108
|
+
error: :currency_missing)
|
|
109
|
+
].compact
|
|
110
|
+
unless valid_invoice_number?(invoice.invoice_number.to_s)
|
|
111
|
+
errors << { field: :invoice_number, error: :number_invalid,
|
|
112
|
+
message: Einvoicing::I18n.t("errors.invoice.number_invalid") }
|
|
113
|
+
end
|
|
114
|
+
if invoice.document_type == :credit_note &&
|
|
115
|
+
invoice.original_invoice_number.to_s.strip.empty?
|
|
116
|
+
errors << { field: :original_invoice_number, error: :original_invoice_number_missing,
|
|
117
|
+
message: Einvoicing::I18n.t("errors.invoice.original_invoice_number_missing") }
|
|
118
|
+
end
|
|
119
|
+
if invoice.iban && !valid_iban?(invoice.iban)
|
|
120
|
+
errors << { field: :iban, error: :iban_invalid,
|
|
121
|
+
message: Einvoicing::I18n.t("errors.invoice.iban_invalid") }
|
|
122
|
+
end
|
|
123
|
+
if invoice.bic && !valid_bic?(invoice.bic)
|
|
124
|
+
errors << { field: :bic, error: :bic_invalid,
|
|
125
|
+
message: Einvoicing::I18n.t("errors.invoice.bic_invalid") }
|
|
126
|
+
end
|
|
127
|
+
errors
|
|
128
|
+
end
|
|
129
|
+
private_class_method :validate_invoice_fields
|
|
130
|
+
|
|
131
|
+
def self.validate_party(party, role)
|
|
132
|
+
name_field = :"#{role}_name"
|
|
133
|
+
errors = [
|
|
134
|
+
Base.presence(party&.name, name_field,
|
|
135
|
+
Einvoicing::I18n.t("errors.#{role}.name_missing"),
|
|
136
|
+
error: :name_missing)
|
|
137
|
+
].compact
|
|
138
|
+
return errors if party.nil?
|
|
139
|
+
|
|
140
|
+
siren = party.siren_number
|
|
141
|
+
if siren && !valid_siren?(siren)
|
|
142
|
+
errors << { field: :"#{role}_siren", error: :siren_invalid,
|
|
143
|
+
message: Einvoicing::I18n.t("errors.#{role}.siren_invalid") }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
if party.siret && !valid_siret?(party.siret)
|
|
147
|
+
errors << { field: :"#{role}_siret", error: :siret_invalid,
|
|
148
|
+
message: Einvoicing::I18n.t("errors.#{role}.siret_invalid") }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
if party.vat_number && !valid_vat_number?(party.vat_number)
|
|
152
|
+
errors << { field: :"#{role}_vat_number", error: :vat_number_invalid,
|
|
153
|
+
message: Einvoicing::I18n.t("errors.#{role}.vat_number_invalid") }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
errors
|
|
157
|
+
end
|
|
158
|
+
private_class_method :validate_party
|
|
159
|
+
|
|
160
|
+
def self.validate_lines(lines)
|
|
161
|
+
if lines.nil? || lines.empty?
|
|
162
|
+
return [{ field: :lines, error: :lines_empty,
|
|
163
|
+
message: Einvoicing::I18n.t("errors.invoice.lines_empty") }]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
lines.each_with_index.flat_map do |line, idx|
|
|
167
|
+
n = idx + 1
|
|
168
|
+
[
|
|
169
|
+
(if line.description.to_s.strip.empty?
|
|
170
|
+
{ field: :"line_#{n}_description", error: :description_missing,
|
|
171
|
+
message: Einvoicing::I18n.t("errors.line.description_missing", index: n) }
|
|
172
|
+
end),
|
|
173
|
+
(unless line.quantity.to_f.positive?
|
|
174
|
+
{ field: :"line_#{n}_quantity", error: :quantity_invalid,
|
|
175
|
+
message: Einvoicing::I18n.t("errors.line.quantity_invalid", index: n) }
|
|
176
|
+
end),
|
|
177
|
+
(if line.unit_price.to_f.negative?
|
|
178
|
+
{ field: :"line_#{n}_unit_price", error: :unit_price_invalid,
|
|
179
|
+
message: Einvoicing::I18n.t("errors.line.unit_price_invalid", index: n) }
|
|
180
|
+
end),
|
|
181
|
+
(unless [0.0, 0.055, 0.10, 0.20].include?(line.vat_rate.to_f.round(3))
|
|
182
|
+
{ field: :"line_#{n}_vat_rate", error: :vat_rate_invalid,
|
|
183
|
+
message: Einvoicing::I18n.t("errors.line.vat_rate_invalid", index: n) }
|
|
184
|
+
end)
|
|
185
|
+
]
|
|
186
|
+
end.compact
|
|
187
|
+
end
|
|
188
|
+
private_class_method :validate_lines
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
data/lib/einvoicing/version.rb
CHANGED