cpro-client 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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rubocop.yml +42 -0
  4. data/CHANGELOG.md +29 -0
  5. data/CODE_OF_CONDUCT.md +84 -0
  6. data/Gemfile +13 -0
  7. data/Gemfile.lock +79 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +310 -0
  10. data/Rakefile +25 -0
  11. data/bin/console +15 -0
  12. data/bin/setup +8 -0
  13. data/cpro-client.gemspec +32 -0
  14. data/lib/cpro/account.rb +32 -0
  15. data/lib/cpro/archive.rb +44 -0
  16. data/lib/cpro/auth/token.rb +36 -0
  17. data/lib/cpro/auth/token_provider.rb +59 -0
  18. data/lib/cpro/cdv/message.rb +230 -0
  19. data/lib/cpro/cdv/statut.rb +80 -0
  20. data/lib/cpro/client.rb +44 -0
  21. data/lib/cpro/configuration.rb +89 -0
  22. data/lib/cpro/connection.rb +19 -0
  23. data/lib/cpro/entities/base.rb +71 -0
  24. data/lib/cpro/entities/changement_statut.rb +27 -0
  25. data/lib/cpro/entities/depot_flux.rb +16 -0
  26. data/lib/cpro/entities/etablissement.rb +96 -0
  27. data/lib/cpro/entities/facture.rb +85 -0
  28. data/lib/cpro/entities/ligne_annuaire.rb +42 -0
  29. data/lib/cpro/entities/lignes_annuaire.rb +45 -0
  30. data/lib/cpro/entities/motif_rejet.rb +24 -0
  31. data/lib/cpro/entities/note_statut.rb +24 -0
  32. data/lib/cpro/entities/resultat_recherche.rb +55 -0
  33. data/lib/cpro/entities/statut_flux.rb +54 -0
  34. data/lib/cpro/entities/unite_legale.rb +47 -0
  35. data/lib/cpro/enveloppe.rb +73 -0
  36. data/lib/cpro/errors.rb +25 -0
  37. data/lib/cpro/facturx.rb +53 -0
  38. data/lib/cpro/middleware/authentication.rb +51 -0
  39. data/lib/cpro/middleware/raise_error.rb +50 -0
  40. data/lib/cpro/resources/annuaire.rb +110 -0
  41. data/lib/cpro/resources/base.rb +61 -0
  42. data/lib/cpro/resources/factures.rb +65 -0
  43. data/lib/cpro/resources/flux.rb +60 -0
  44. data/lib/cpro/version.rb +5 -0
  45. data/lib/cpro-client.rb +3 -0
  46. data/lib/cpro.rb +56 -0
  47. metadata +106 -0
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/cpro/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "cpro-client"
7
+ spec.version = Cpro::VERSION
8
+ spec.authors = ["Hôtentic"]
9
+ spec.email = ["contact@hotentic.com"]
10
+
11
+ spec.summary = "A ruby client for the Chorus Pro API"
12
+ spec.description = "Client Ruby des API G2B de Chorus Pro (PPF) : authentification OAuth2 PISTE, " \
13
+ "consultation de l'annuaire par SIRET ou SIREN, et dépôt de factures Factur-X."
14
+ spec.homepage = "https://hotentic.com"
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 2.7.0"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = "https://github.com/hotentic/cpro-client"
20
+ spec.metadata["changelog_uri"] = "https://github.com/hotentic/cpro-client/releases"
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
26
+ end
27
+ spec.bindir = "exe"
28
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
+ spec.require_paths = ["lib"]
30
+
31
+ spec.add_dependency "faraday", "~> 2.0"
32
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Cpro
6
+ class Account
7
+ attr_reader :login
8
+
9
+ def initialize(login:, password:)
10
+ raise ConfigurationError, "login est requis" if blank?(login)
11
+ raise ConfigurationError, "password est requis" if blank?(password)
12
+
13
+ @login = login.to_s
14
+ @password = password.to_s
15
+ end
16
+
17
+ def header_value
18
+ Base64.strict_encode64("#{login}:#{@password}")
19
+ end
20
+
21
+ def inspect
22
+ "#<#{self.class.name} login=#{login.inspect}>"
23
+ end
24
+ alias to_s inspect
25
+
26
+ private
27
+
28
+ def blank?(value)
29
+ value.nil? || value.to_s.empty?
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/package"
4
+ require "stringio"
5
+ require "zlib"
6
+
7
+ module Cpro
8
+ # Le PPF n'accepte les flux qu'au format tar.gz, y compris pour une facture unique
9
+ # (Spécifications externes Chorus Pro v6.1, §3.2.3).
10
+ module Archive
11
+ FILE_MODE = 0o644
12
+
13
+ def self.gzip(entries)
14
+ raise ArgumentError, "au moins une entrée est requise" if entries.empty?
15
+
16
+ compress(tar(entries))
17
+ end
18
+
19
+ def self.tar(entries)
20
+ buffer = StringIO.new(+"".b)
21
+ Gem::Package::TarWriter.new(buffer) do |writer|
22
+ entries.each do |entry|
23
+ content = entry.fetch(:content).to_s.b
24
+ writer.add_file(entry.fetch(:name), FILE_MODE) { |io| io.write(content) }
25
+ end
26
+ end
27
+ buffer.string
28
+ end
29
+
30
+ def self.compress(content)
31
+ buffer = StringIO.new(+"".b)
32
+ Zlib::GzipWriter.wrap(buffer) { |gz| gz.write(content) }
33
+ buffer.string
34
+ end
35
+
36
+ def self.entries(archive)
37
+ Zlib::GzipReader.wrap(StringIO.new(archive)) do |gz|
38
+ Gem::Package::TarReader.new(StringIO.new(gz.read)).map do |entry|
39
+ { name: entry.full_name, content: entry.read.to_s }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Auth
5
+ class Token
6
+ # Marge de renouvellement anticipé, pour absorber la latence réseau et la dérive d'horloge.
7
+ EXPIRY_MARGIN = 60
8
+ DEFAULT_LIFETIME = 3600
9
+
10
+ attr_reader :value, :expires_at
11
+
12
+ def self.from_response(body, now: Time.now)
13
+ payload = body.is_a?(Hash) ? body : {}
14
+ value = payload["access_token"]
15
+ raise AuthenticationError.new("réponse PISTE sans access_token", body: body) if value.nil? || value.empty?
16
+
17
+ lifetime = (payload["expires_in"] || DEFAULT_LIFETIME).to_i
18
+ new(value: value, expires_at: now + lifetime)
19
+ end
20
+
21
+ def initialize(value:, expires_at:)
22
+ @value = value
23
+ @expires_at = expires_at
24
+ end
25
+
26
+ def expired?(now: Time.now)
27
+ now >= (expires_at - EXPIRY_MARGIN)
28
+ end
29
+
30
+ def inspect
31
+ "#<#{self.class.name} expires_at=#{expires_at.inspect}>"
32
+ end
33
+ alias to_s inspect
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Auth
5
+ class TokenProvider
6
+ def initialize(configuration)
7
+ @configuration = configuration
8
+ @mutex = Mutex.new
9
+ @token = nil
10
+ end
11
+
12
+ def token
13
+ @mutex.synchronize do
14
+ @token = fetch if @token.nil? || @token.expired?
15
+ @token
16
+ end
17
+ end
18
+
19
+ def invalidate!
20
+ @mutex.synchronize { @token = nil }
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :configuration
26
+
27
+ def fetch
28
+ configuration.validate!
29
+ response = connection.post(configuration.token_url) { |request| request.body = credentials }
30
+ Token.from_response(response.body)
31
+ rescue ApiError => e
32
+ raise e if e.is_a?(AuthenticationError)
33
+
34
+ raise AuthenticationError.new("échec de l'obtention du jeton PISTE : #{e.message}",
35
+ status: e.status, body: e.body, correlation_id: e.correlation_id)
36
+ end
37
+
38
+ def credentials
39
+ {
40
+ grant_type: "client_credentials",
41
+ client_id: configuration.client_id,
42
+ client_secret: configuration.client_secret,
43
+ scope: configuration.scope
44
+ }
45
+ end
46
+
47
+ def connection
48
+ @connection ||= Faraday.new do |faraday|
49
+ faraday.request :url_encoded
50
+ faraday.use Middleware::RaiseError
51
+ faraday.response :json, content_type: /\bjson$/
52
+ faraday.options.timeout = configuration.timeout
53
+ faraday.options.open_timeout = configuration.open_timeout
54
+ faraday.adapter configuration.adapter
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+
5
+ module Cpro
6
+ module Cdv
7
+ # Construit un message de cycle de vie au format CDAR D22B, conforme à l'Annexe A de la norme
8
+ # AFNOR XP Z12-012 (feuillet « CDV FE - CDAR »).
9
+ #
10
+ # L'ordre des éléments suit celui du feuillet, qui liste les données dans l'ordre du schéma :
11
+ # faute de XSD dans les documents disponibles, c'est le meilleur signal dont on dispose.
12
+ class Message
13
+ # Relevés du XSD CDAR D22B (CrossDomainAcknowledgementAndResponse_100pD22B.xsd).
14
+ NAMESPACES = {
15
+ "xmlns:rsm" => "urn:un:unece:uncefact:data:standard:CrossDomainAcknowledgementAndResponse:100",
16
+ "xmlns:ram" => "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100",
17
+ "xmlns:qdt" => "urn:un:unece:uncefact:data:standard:QualifiedDataType:100",
18
+ "xmlns:udt" => "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
19
+ }.freeze
20
+
21
+ PROFIL = "urn.cpro.gouv.fr:1p0:CDV:invoice" # MDT-3, BR-FR-CDV-02
22
+ PROCESSUS = "REGULATED" # MDT-2, BR-FR-CDV-CL-01
23
+ SCHEME_SIRET = "0009" # ICD 6523
24
+ ROLE_VENDEUR = "SE" # BR-FR-CDV-CL-02/03/07
25
+ ROLE_ACHETEUR = "BY" # BR-FR-CDV-CL-04
26
+ SCHEME_ADRESSAGE = "0225" # EAS : adresse annuaire national, MDT-73-1
27
+ FORMAT_DATE_HEURE = "204" # UNTDID 2379 : AAAAMMJJHHMMSS
28
+ FORMAT_DATE = "102" # AAAAMMJJ
29
+ TYPE_MONTANT_ENCAISSE = "MEN" # MDT-207, BR-FR-CDV-14
30
+ # MDT-97, règle G7.14 : URN du type d'objet sur lequel porte le cycle de vie.
31
+ TYPE_REFERENCE_FACTURE = "urn.cpro.gouv.fr:1p0:CDV:einvoicingF2"
32
+
33
+ attr_reader :facture, :statut
34
+
35
+ def initialize(facture:, statut:, emetteur: nil, destinataire: nil, motif: nil,
36
+ montant: nil, taux_tva: nil, identifiant: nil, horodatage: nil,
37
+ type_reference: TYPE_REFERENCE_FACTURE, raison_sociale_emetteur: nil,
38
+ raison_sociale_destinataire: nil, adressage_destinataire: nil)
39
+ @facture = facture
40
+ @statut = Statut[statut]
41
+ @emetteur = emetteur || facture.siret_vendeur
42
+ @raison_sociale_emetteur = raison_sociale_emetteur || facture.raison_sociale_vendeur
43
+ @destinataire = destinataire || facture.siret_acheteur
44
+ @raison_sociale_destinataire = raison_sociale_destinataire || facture.raison_sociale_acheteur
45
+ @adressage_destinataire = adressage_destinataire || facture.id_adressage_acheteur
46
+ @motif = motif
47
+ @montant = montant
48
+ @taux_tva = taux_tva
49
+ @type_reference = type_reference
50
+ @identifiant = identifiant
51
+ @horodatage = horodatage
52
+ valider!
53
+ end
54
+
55
+ def identifiant
56
+ @identifiant ||= "CDV-#{facture.numero}-#{@statut.code}"
57
+ end
58
+
59
+ def horodatage
60
+ @horodatage ||= Time.now.utc
61
+ end
62
+
63
+ def to_xml
64
+ document = REXML::Document.new
65
+ document << REXML::XMLDecl.new("1.0", "UTF-8")
66
+ racine = document.add_element("rsm:CrossDomainAcknowledgementAndResponse", NAMESPACES)
67
+ contexte(racine)
68
+ document_echange(racine)
69
+ accuse(racine)
70
+
71
+ rendu = +""
72
+ REXML::Formatters::Pretty.new(2).tap { |f| f.compact = true }.write(document, rendu)
73
+ "#{rendu}\n"
74
+ end
75
+
76
+ def nom_fichier
77
+ "#{identifiant}.xml"
78
+ end
79
+
80
+ private
81
+
82
+ # Les manques sont accumulés puis levés d'un bloc : un appelant à qui il manque trois données
83
+ # préfère les voir toutes que de les découvrir une par une.
84
+ def valider!
85
+ manquants = manques
86
+ return if manquants.empty?
87
+
88
+ raise ArgumentError, "CDV #{@statut} incomplet, il manque : #{manquants.join(", ")}"
89
+ end
90
+
91
+ def manques
92
+ donnees_requises.merge(donnees_conditionnelles).select { |_, manquant| manquant }.keys
93
+ end
94
+
95
+ def donnees_requises
96
+ {
97
+ "le numéro de facture (MDT-87)" => blank?(facture.numero),
98
+ "le SIRET de l'émetteur (MDT-19)" => blank?(@emetteur),
99
+ "la date d'émission de la facture (MDT-100)" => facture.date_emission.nil?,
100
+ "la raison sociale de l'émetteur (MDT-20, G7.46)" => blank?(@raison_sociale_emetteur)
101
+ }
102
+ end
103
+
104
+ def donnees_conditionnelles
105
+ {
106
+ "l'identifiant d'adressage du destinataire (MDT-73, BR-FR-CDV-08)" =>
107
+ !blank?(@destinataire) && blank?(@adressage_destinataire),
108
+ "un motif de statut (MDT-113)" => @statut.motif_requis? && blank?(@motif),
109
+ "un motif admis pour ce statut #{@statut.motifs_admis&.join(", ")} (BR-FR-CDV-CL-09)" =>
110
+ !blank?(@motif) && !@statut.motif_admis?(@motif),
111
+ "un montant et un taux de TVA (BR-FR-CDV-14)" => montant_manquant?
112
+ }
113
+ end
114
+
115
+ def montant_manquant?
116
+ @statut.montant_requis? && (@montant.nil? || @taux_tva.nil?)
117
+ end
118
+
119
+ def contexte(parent)
120
+ contexte = parent.add_element("rsm:ExchangedDocumentContext")
121
+ contexte.add_element("ram:BusinessProcessSpecifiedDocumentContextParameter")
122
+ .add_element("ram:ID").add_text(PROCESSUS)
123
+ contexte.add_element("ram:GuidelineSpecifiedDocumentContextParameter")
124
+ .add_element("ram:ID").add_text(PROFIL)
125
+ end
126
+
127
+ def document_echange(parent)
128
+ echange = parent.add_element("rsm:ExchangedDocument")
129
+ echange.add_element("ram:ID").add_text(identifiant)
130
+ echange.add_element("ram:Name").add_text("Cycle de vie #{@statut}")
131
+ horodate(echange.add_element("ram:IssueDateTime"), horodatage)
132
+ emettrice(echange.add_element("ram:SenderTradeParty"))
133
+ emettrice(echange.add_element("ram:IssuerTradeParty"))
134
+ return if blank?(@destinataire)
135
+
136
+ destinataire(echange.add_element("ram:RecipientTradeParty"))
137
+ end
138
+
139
+ # BR-FR-CDV-08 : le rôle du destinataire n'étant ni WK ni DFH, son adresse électronique est
140
+ # obligatoire. C'est l'identifiant d'adressage de l'annuaire de la facturation.
141
+ def destinataire(noeud)
142
+ partie(noeud, @destinataire, ROLE_ACHETEUR, @raison_sociale_destinataire)
143
+ noeud.add_element("ram:URIUniversalCommunication")
144
+ .add_element("ram:URIID", "schemeID" => SCHEME_ADRESSAGE)
145
+ .add_text(@adressage_destinataire.to_s)
146
+ end
147
+
148
+ def accuse(parent)
149
+ accuse = parent.add_element("rsm:AcknowledgementDocument")
150
+ accuse.add_element("ram:MultipleReferencesIndicator")
151
+ .add_element("udt:Indicator").add_text("false")
152
+ accuse.add_element("ram:TypeCode").add_text(@statut.phase)
153
+ horodate(accuse.add_element("ram:IssueDateTime"), horodatage)
154
+ reference(accuse.add_element("ram:ReferenceReferencedDocument"))
155
+ end
156
+
157
+ def reference(reference)
158
+ objet_reference(reference)
159
+ statut_reference(reference)
160
+ end
161
+
162
+ # Identification de la facture sur laquelle porte le CDV. L'ordre suit la séquence du XSD
163
+ # (ReferencedDocumentType), qui diffère de celui du feuillet de la norme.
164
+ def objet_reference(reference)
165
+ reference.add_element("ram:IssuerAssignedID").add_text(facture.numero)
166
+ reference.add_element("ram:StatusCode").add_text(@statut.untdid)
167
+ reference.add_element("ram:TypeCode").add_text(facture.code_type.to_s)
168
+ horodate(reference.add_element("ram:ReceiptDateTime"), date_reception)
169
+ end
170
+
171
+ def statut_reference(reference)
172
+ type_reference(reference)
173
+ date(reference.add_element("ram:FormattedIssueDateTime"), facture.date_emission)
174
+ reference.add_element("ram:ProcessConditionCode").add_text(@statut.code)
175
+ emettrice(reference.add_element("ram:IssuerTradeParty"))
176
+ detail_statut(reference.add_element("ram:SpecifiedDocumentStatus"))
177
+ end
178
+
179
+ # MDT-97, obligatoire, mais sa liste de codes renvoie à l'annexe 7 des spécifications
180
+ # externes, absente des documents disponibles : la valeur doit être fournie par l'appelant.
181
+ def type_reference(reference)
182
+ return if blank?(@type_reference)
183
+
184
+ reference.add_element("ram:ReferenceTypeCode").add_text(@type_reference.to_s)
185
+ end
186
+
187
+ def detail_statut(detail)
188
+ detail.add_element("ram:ReasonCode").add_text(@motif.to_s) unless blank?(@motif)
189
+ detail.add_element("ram:SequenceNumeric").add_text("1")
190
+ return unless @statut.montant_requis?
191
+
192
+ caracteristique = detail.add_element("ram:SpecifiedDocumentCharacteristic")
193
+ caracteristique.add_element("ram:TypeCode").add_text(TYPE_MONTANT_ENCAISSE)
194
+ caracteristique.add_element("ram:ValueAmount").add_text(format("%.2f", @montant))
195
+ caracteristique.add_element("ram:ValuePercent").add_text(format("%.2f", @taux_tva))
196
+ end
197
+
198
+ def emettrice(noeud)
199
+ partie(noeud, @emetteur, ROLE_VENDEUR, @raison_sociale_emetteur)
200
+ end
201
+
202
+ # G7.46 : la raison sociale est obligatoire dès lors que le rôle n'est ni WK ni DFH.
203
+ def partie(noeud, identifiant, role, raison_sociale)
204
+ noeud.add_element("ram:GlobalID", "schemeID" => SCHEME_SIRET).add_text(identifiant.to_s)
205
+ noeud.add_element("ram:Name").add_text(raison_sociale.to_s) unless blank?(raison_sociale)
206
+ noeud.add_element("ram:RoleCode").add_text(role)
207
+ end
208
+
209
+ def horodate(noeud, instant)
210
+ noeud.add_element("udt:DateTimeString", "format" => FORMAT_DATE_HEURE)
211
+ .add_text(instant.utc.strftime("%Y%m%d%H%M%S"))
212
+ end
213
+
214
+ def date(noeud, jour)
215
+ noeud.add_element("qdt:DateTimeString", "format" => FORMAT_DATE)
216
+ .add_text(jour.strftime("%Y%m%d"))
217
+ end
218
+
219
+ # MDT-95 : date de réception de l'objet, soit pour l'émetteur la date du statut « Déposée ».
220
+ def date_reception
221
+ depot = facture.historique.find { |changement| changement.statut == "DEPOSEE" }
222
+ depot&.date || facture.date_statut || horodatage
223
+ end
224
+
225
+ def blank?(value)
226
+ value.nil? || value.to_s.strip.empty?
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Cdv
5
+ # Catalogue des statuts qu'un émetteur peut poser sur sa propre facture en G2B, d'après
6
+ # l'annexe « Correspondance codes interfaces / CDV / statuts » de Chorus Pro (feuillet G2B).
7
+ # Tous relèvent de la phase Traitement, d'où MDT-77 = 23.
8
+ class Statut
9
+ PHASE_TRAITEMENT = "23"
10
+
11
+ # untdid : code UNTDID 1373 attendu en MDT-88 (BR-FR-CDV-CL-05).
12
+ # motif : MDT-113 obligatoire (BR-FR-CDV-15).
13
+ # montant : bloc MDG-43 requis avec MDT-207 = MEN (BR-FR-CDV-14).
14
+ CATALOGUE = {
15
+ approuvee: { code: "205", untdid: "1", obligatoire: false },
16
+ completee: { code: "209", untdid: "37", obligatoire: false },
17
+ refusee: { code: "210", untdid: "50", obligatoire: true, motif: true },
18
+ encaissee: { code: "212", untdid: "47", obligatoire: true, montant: true },
19
+ visee: { code: "214", untdid: "5", obligatoire: false },
20
+ annulee: { code: "220", untdid: "5", obligatoire: false },
21
+ changement_de_compte_a_payer: { code: "227", untdid: "5", obligatoire: false }
22
+ }.freeze
23
+
24
+ # Listes fermées des motifs admis par statut, relevées du Schematron BR-FR-CDV
25
+ # (BR-FR-CDV-CL-09). Le PPF rejette tout autre code.
26
+ MOTIFS = {
27
+ "210" => %w[TX_TVA_ERR MONTANTTOTAL_ERR CALCUL_ERR NON_CONFORME DOUBLON DEST_ERR TRANSAC_INC
28
+ EMMET_INC CONTRAT_TERM DOUBLE_FACT CMD_ERR ADR_ERR REF_CT_ABSENT].freeze
29
+ }.freeze
30
+
31
+ attr_reader :nom, :code, :untdid
32
+
33
+ def self.[](nom)
34
+ definition = CATALOGUE[nom.to_s.downcase.to_sym]
35
+ return new(nom.to_s.downcase.to_sym, definition) if definition
36
+
37
+ raise ArgumentError,
38
+ "statut inconnu : #{nom.inspect} (attendus : #{CATALOGUE.keys.join(", ")})"
39
+ end
40
+
41
+ def initialize(nom, definition)
42
+ @nom = nom
43
+ @code = definition.fetch(:code)
44
+ @untdid = definition.fetch(:untdid)
45
+ @definition = definition
46
+ end
47
+
48
+ # Nil lorsque la norme ne restreint pas les motifs pour ce statut.
49
+ def motifs_admis
50
+ MOTIFS[code]
51
+ end
52
+
53
+ def motif_admis?(motif)
54
+ admis = motifs_admis
55
+ admis.nil? || admis.include?(motif.to_s)
56
+ end
57
+
58
+ def motif_requis?
59
+ @definition.fetch(:motif, false)
60
+ end
61
+
62
+ def montant_requis?
63
+ @definition.fetch(:montant, false)
64
+ end
65
+
66
+ # Statut dont l'émission est obligatoire au titre de la réforme.
67
+ def obligatoire?
68
+ @definition.fetch(:obligatoire)
69
+ end
70
+
71
+ def phase
72
+ PHASE_TRAITEMENT
73
+ end
74
+
75
+ def to_s
76
+ nom.to_s
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ class Client
5
+ ANNUAIRE_PATH = "/g2b/annuaire/v1"
6
+ FLUX_PATH = "/g2b/flux/v1"
7
+ FACTURES_PATH = "/g2b/factures/v1"
8
+
9
+ attr_reader :configuration, :account
10
+
11
+ def initialize(configuration = Cpro.configuration, account: nil, token_provider: nil, connections: nil)
12
+ @configuration = configuration
13
+ @account = account || configuration.account
14
+ @token_provider = token_provider || configuration.token_provider
15
+ @connections = connections || {}
16
+ end
17
+
18
+ def with_account(account)
19
+ self.class.new(configuration, account: account, token_provider: token_provider, connections: connections)
20
+ end
21
+
22
+ def annuaire
23
+ @annuaire ||= Resources::Annuaire.new(self, connection_for(ANNUAIRE_PATH))
24
+ end
25
+
26
+ def flux
27
+ @flux ||= Resources::Flux.new(self, connection_for(FLUX_PATH))
28
+ end
29
+
30
+ def factures
31
+ @factures ||= Resources::Factures.new(self, connection_for(FACTURES_PATH))
32
+ end
33
+
34
+ protected
35
+
36
+ attr_reader :token_provider, :connections
37
+
38
+ private
39
+
40
+ def connection_for(base_path)
41
+ connections[base_path] ||= Connection.build(configuration, token_provider, base_path)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ class Configuration
5
+ ENVIRONMENTS = {
6
+ sandbox: {
7
+ api_host: "https://sandbox-api.piste.gouv.fr",
8
+ token_url: "https://sandbox-oauth.piste.gouv.fr/api/oauth/token"
9
+ },
10
+ production: {
11
+ api_host: "https://api.piste.gouv.fr",
12
+ token_url: "https://oauth.piste.gouv.fr/api/oauth/token"
13
+ }
14
+ }.freeze
15
+
16
+ DEFAULT_SCOPE = "openid"
17
+ DEFAULT_TIMEOUT = 30
18
+ DEFAULT_OPEN_TIMEOUT = 10
19
+
20
+ attr_accessor :account, :timeout, :open_timeout, :logger, :adapter
21
+ attr_reader :environment, :client_id, :client_secret, :scope
22
+
23
+ def initialize
24
+ @environment = :sandbox
25
+ @scope = DEFAULT_SCOPE
26
+ @timeout = DEFAULT_TIMEOUT
27
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
28
+ @adapter = Faraday.default_adapter
29
+ @token_provider = nil
30
+ end
31
+
32
+ def token_provider
33
+ @token_provider ||= Auth::TokenProvider.new(self)
34
+ end
35
+
36
+ def environment=(name)
37
+ environment = name.to_sym
38
+ unless ENVIRONMENTS.key?(environment)
39
+ raise ConfigurationError,
40
+ "environnement inconnu : #{name.inspect} (attendus : #{ENVIRONMENTS.keys.join(", ")})"
41
+ end
42
+
43
+ @environment = environment
44
+ discard_token!
45
+ end
46
+
47
+ def client_id=(value)
48
+ @client_id = value
49
+ discard_token!
50
+ end
51
+
52
+ def client_secret=(value)
53
+ @client_secret = value
54
+ discard_token!
55
+ end
56
+
57
+ def scope=(value)
58
+ @scope = value
59
+ discard_token!
60
+ end
61
+
62
+ def api_host
63
+ ENVIRONMENTS.fetch(environment).fetch(:api_host)
64
+ end
65
+
66
+ def token_url
67
+ ENVIRONMENTS.fetch(environment).fetch(:token_url)
68
+ end
69
+
70
+ def validate!
71
+ raise ConfigurationError, "client_id est requis" if blank?(client_id)
72
+ raise ConfigurationError, "client_secret est requis" if blank?(client_secret)
73
+
74
+ self
75
+ end
76
+
77
+ private
78
+
79
+ # Le fournisseur est conservé pour que les clients déjà construits voient les nouveaux
80
+ # identifiants ; seul le jeton en cache est jeté.
81
+ def discard_token!
82
+ @token_provider&.invalidate!
83
+ end
84
+
85
+ def blank?(value)
86
+ value.nil? || value.to_s.strip.empty?
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Connection
5
+ # Middlewares ordering matters here
6
+ def self.build(configuration, token_provider, base_path)
7
+ Faraday.new(url: "#{configuration.api_host}#{base_path}/") do |faraday|
8
+ faraday.request :json
9
+ faraday.use Middleware::Authentication, token_provider: token_provider
10
+ faraday.use Middleware::RaiseError
11
+ faraday.response :json, content_type: /\bjson$/
12
+ faraday.response :logger, configuration.logger if configuration.logger
13
+ faraday.options.timeout = configuration.timeout
14
+ faraday.options.open_timeout = configuration.open_timeout
15
+ faraday.adapter configuration.adapter
16
+ end
17
+ end
18
+ end
19
+ end