einvoicing-connect 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 54ce574178b7dcbed5b437a34f88672ae3d0d5923f7262d679cbc927d582265e
4
+ data.tar.gz: 96f661a9749c2b1c41b79bc5f52c9c48823bef8d314a7432e783137c560be7c8
5
+ SHA512:
6
+ metadata.gz: 4d32efd286fb9e98e0b3e9038e409e40f4f44f9d3b641e4dccb94584e4866daa076146799d8d2985dfbe5799966d8f3c8af43332d9f70a8daafeeface89c9909
7
+ data.tar.gz: 03df43147ea54a77683c0775bfda4f94c0e492cfca4427c8e7a1ded0215d266f79fe845984fb7864ef70f783cf13fb93d80f848ed8656860ebb5787a4ed47c90
@@ -0,0 +1,13 @@
1
+ en:
2
+ einvoicing:
3
+ connect:
4
+ ppf:
5
+ auth_failed: "OAuth token request failed: %{code} %{body}"
6
+ unauthorized: "Unauthorized: %{body}"
7
+ forbidden: "Forbidden: %{body}"
8
+ not_found: "Not found: %{body}"
9
+ api_error: "API error %{code}: %{body}"
10
+ structure_not_found: "Buyer SIRET %{siret} not found in Chorus Pro"
11
+ fr:
12
+ siret_api_error: "SIRET lookup failed: %{message}"
13
+ siret_not_found: "No company found for SIREN: %{siren}"
@@ -0,0 +1,13 @@
1
+ fr:
2
+ einvoicing:
3
+ connect:
4
+ ppf:
5
+ auth_failed: "Échec de la requête de jeton OAuth : %{code} %{body}"
6
+ unauthorized: "Non autorisé : %{body}"
7
+ forbidden: "Accès interdit : %{body}"
8
+ not_found: "Non trouvé : %{body}"
9
+ api_error: "Erreur API %{code} : %{body}"
10
+ structure_not_found: "SIRET acheteur %{siret} non trouvé dans Chorus Pro"
11
+ fr:
12
+ siret_api_error: "Échec de la recherche SIRET : %{message}"
13
+ siret_not_found: "Aucune entreprise trouvée pour le SIREN : %{siren}"
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+
5
+ module Einvoicing
6
+ module Connect
7
+ module I18nSetup
8
+ def self.setup
9
+ ::I18n.load_path += Dir[File.join(__dir__, "../../../config/locales/*.yml")]
10
+ ::I18n.backend.load_translations
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ Einvoicing::Connect::I18nSetup.setup
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Einvoicing
4
+ module Connect
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "connect/version"
4
+
5
+ module Einvoicing
6
+ module Connect
7
+ # einvoicing-connect adds platform connectivity to the einvoicing gem.
8
+ # Requires: gem "einvoicing-connect" in your Gemfile.
9
+ end
10
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Einvoicing
8
+ module FR
9
+ module SiretLookup
10
+ API_URL = "https://recherche-entreprises.api.gouv.fr/search" unless defined?(API_URL)
11
+
12
+ # Find SIRET for a given SIREN using the French government Sirene API.
13
+ # Returns { siret:, name:, address: } or nil on any error.
14
+ def self.find(siren)
15
+ return nil unless siren.to_s.match?(/\A\d{9}\z/)
16
+
17
+ uri = URI(API_URL)
18
+ uri.query = URI.encode_www_form(q: siren.to_s, mtq: "true")
19
+
20
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
21
+ open_timeout: 5, read_timeout: 10) do |http|
22
+ http.get(uri.request_uri)
23
+ end
24
+
25
+ return nil unless response.code == "200"
26
+
27
+ data = JSON.parse(response.body)
28
+ result = data["results"]&.first
29
+ return nil unless result
30
+
31
+ siege = result["siege"] || {}
32
+ siret = siege["siret"]
33
+ return nil if siret.nil? || siret.empty?
34
+
35
+ { siret: siret, name: result["nom_complet"], address: siege["adresse"] }
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ # Enrich a Party object by fetching and setting its SIRET from the API.
41
+ # Only calls the API if party.siren is present and party.siret is blank.
42
+ # Returns the party.
43
+ def self.enrich!(party)
44
+ return party if party.siren.to_s.strip.empty?
45
+ return party if party.respond_to?(:siret) && !party.siret.to_s.strip.empty?
46
+
47
+ result = find(party.siren.to_s.gsub(/\s/, ""))
48
+ return party unless result&.dig(:siret)
49
+
50
+ party.with(siret: result[:siret])
51
+ end
52
+ end
53
+ end
54
+ 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" unless defined?(SANDBOX_OAUTH_URL)
10
+ SANDBOX_API_URL = "https://sandbox-api.piste.gouv.fr/cpro/factures" unless defined?(SANDBOX_API_URL)
11
+ PROD_OAUTH_URL = "https://oauth.piste.gouv.fr" unless defined?(PROD_OAUTH_URL)
12
+ PROD_API_URL = "https://api.piste.gouv.fr/cpro/factures" unless defined?(PROD_API_URL)
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, ::I18n.t("einvoicing.connect.ppf.auth_failed", code: res.code, body: 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, ::I18n.t("einvoicing.connect.ppf.unauthorized", body: body)
107
+ when Net::HTTPForbidden
108
+ raise AuthorizationError, ::I18n.t("einvoicing.connect.ppf.forbidden", body: body)
109
+ when Net::HTTPNotFound
110
+ raise NotFoundError, ::I18n.t("einvoicing.connect.ppf.not_found", body: body)
111
+ else
112
+ raise APIError, ::I18n.t("einvoicing.connect.ppf.api_error", code: res.code, body: 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) unless defined?(Error)
6
+ AuthenticationError = Class.new(Error) unless defined?(AuthenticationError)
7
+ AuthorizationError = Class.new(Error) unless defined?(AuthorizationError)
8
+ NotFoundError = Class.new(Error) unless defined?(NotFoundError)
9
+ APIError = Class.new(Error) unless defined?(APIError)
10
+ ValidationError = Class.new(Error) unless defined?(ValidationError)
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, ::I18n.t("einvoicing.connect.ppf.structure_not_found", siret: invoice.buyer.siret) 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,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "einvoicing"
4
+ require_relative "einvoicing/connect/version"
5
+ require_relative "einvoicing/connect/i18n"
6
+ require_relative "einvoicing/fr/siret_lookup"
7
+ require_relative "einvoicing/ppf/errors"
8
+ require_relative "einvoicing/ppf/client"
9
+ require_relative "einvoicing/ppf/invoice_adapter"
10
+ require_relative "einvoicing/ppf/submitter"
11
+
12
+ module Einvoicing
13
+ module Connect
14
+ # einvoicing-connect adds platform connectivity to the einvoicing gem.
15
+ # Requires: gem "einvoicing-connect" in your Gemfile.
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,141 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: einvoicing-connect
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan Le Ray
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: einvoicing
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.5'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.13'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: webmock
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.70'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.70'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop-rails-omakase
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop-rspec
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.0'
97
+ description: Adds French PPF/Chorus Pro invoice submission and SIRET lookup to the
98
+ einvoicing gem.
99
+ email:
100
+ - nathan@sxnlabs.com
101
+ executables: []
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - config/locales/en.yml
106
+ - config/locales/fr.yml
107
+ - lib/einvoicing-connect.rb
108
+ - lib/einvoicing/connect.rb
109
+ - lib/einvoicing/connect/i18n.rb
110
+ - lib/einvoicing/connect/version.rb
111
+ - lib/einvoicing/fr/siret_lookup.rb
112
+ - lib/einvoicing/ppf/client.rb
113
+ - lib/einvoicing/ppf/errors.rb
114
+ - lib/einvoicing/ppf/invoice_adapter.rb
115
+ - lib/einvoicing/ppf/submitter.rb
116
+ homepage: https://www.sxnlabs.com/en/gems/einvoicing/
117
+ licenses:
118
+ - MIT
119
+ metadata:
120
+ homepage_uri: https://www.sxnlabs.com/en/gems/einvoicing/
121
+ source_code_uri: https://github.com/sxnlabs/einvoicing-connect
122
+ post_install_message:
123
+ rdoc_options: []
124
+ require_paths:
125
+ - lib
126
+ required_ruby_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '3.2'
131
+ required_rubygems_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ requirements: []
137
+ rubygems_version: 3.5.22
138
+ signing_key:
139
+ specification_version: 4
140
+ summary: Platform connectors for the einvoicing gem — PPF/Chorus Pro, SIRET lookup
141
+ test_files: []