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.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rubocop.yml +42 -0
- data/CHANGELOG.md +29 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +79 -0
- data/LICENSE.txt +21 -0
- data/README.md +310 -0
- data/Rakefile +25 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/cpro-client.gemspec +32 -0
- data/lib/cpro/account.rb +32 -0
- data/lib/cpro/archive.rb +44 -0
- data/lib/cpro/auth/token.rb +36 -0
- data/lib/cpro/auth/token_provider.rb +59 -0
- data/lib/cpro/cdv/message.rb +230 -0
- data/lib/cpro/cdv/statut.rb +80 -0
- data/lib/cpro/client.rb +44 -0
- data/lib/cpro/configuration.rb +89 -0
- data/lib/cpro/connection.rb +19 -0
- data/lib/cpro/entities/base.rb +71 -0
- data/lib/cpro/entities/changement_statut.rb +27 -0
- data/lib/cpro/entities/depot_flux.rb +16 -0
- data/lib/cpro/entities/etablissement.rb +96 -0
- data/lib/cpro/entities/facture.rb +85 -0
- data/lib/cpro/entities/ligne_annuaire.rb +42 -0
- data/lib/cpro/entities/lignes_annuaire.rb +45 -0
- data/lib/cpro/entities/motif_rejet.rb +24 -0
- data/lib/cpro/entities/note_statut.rb +24 -0
- data/lib/cpro/entities/resultat_recherche.rb +55 -0
- data/lib/cpro/entities/statut_flux.rb +54 -0
- data/lib/cpro/entities/unite_legale.rb +47 -0
- data/lib/cpro/enveloppe.rb +73 -0
- data/lib/cpro/errors.rb +25 -0
- data/lib/cpro/facturx.rb +53 -0
- data/lib/cpro/middleware/authentication.rb +51 -0
- data/lib/cpro/middleware/raise_error.rb +50 -0
- data/lib/cpro/resources/annuaire.rb +110 -0
- data/lib/cpro/resources/base.rb +61 -0
- data/lib/cpro/resources/factures.rb +65 -0
- data/lib/cpro/resources/flux.rb +60 -0
- data/lib/cpro/version.rb +5 -0
- data/lib/cpro-client.rb +3 -0
- data/lib/cpro.rb +56 -0
- metadata +106 -0
data/cpro-client.gemspec
ADDED
|
@@ -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
|
data/lib/cpro/account.rb
ADDED
|
@@ -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
|
data/lib/cpro/archive.rb
ADDED
|
@@ -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
|
data/lib/cpro/client.rb
ADDED
|
@@ -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
|