Cpro::Client
Client Ruby des API G2B de Chorus Pro (Portail Public de Facturation), exposées via la plateforme PISTE. La gem couvre le raccordement en mode API ; la connexion EDI est hors périmètre.
Scénario couvert :
- authentification OAuth2 auprès de PISTE ;
- recherche d'une structure dans l'annuaire par SIRET ou par SIREN ;
- transmission d'une facture Factur-X et suivi du flux.
Installation
gem "cpro-client"
Configuration
Les identifiants PISTE identifient l'application et sont stables :
Cpro.configure do |config|
config.environment = :sandbox # ou :production
config.client_id = ENV["PISTE_CLIENT_ID"]
config.client_secret = ENV["PISTE_CLIENT_SECRET"]
end
Le jeton OAuth est obtenu à la demande, renouvelé automatiquement, et mis en cache sur la
configuration — pas sur le client. Construire un Cpro.client à chaque requête ne redemande donc
pas de jeton. Un appel refusé en 401 est rejoué une fois avec un jeton neuf.
Compte technique
Toutes les API G2B exigent, en plus du jeton OAuth, un compte technique Chorus Pro transmis
dans l'en-tête cpro-account. Le swagger Annuaire ne le déclare pas, mais l'API le rejette en 400
sans lui — vérifié en sandbox.
Comme le compte peut varier d'un appel à l'autre, il se passe par with_account plutôt que d'être
figé à la configuration. Un client sans compte lève une Cpro::ConfigurationError avant tout appel
réseau.
compte = Cpro::Account.new(login: "...", password: "...")
client = Cpro.client.with_account(compte)
Le compte technique doit être rattaché à une structure publique, faute de quoi l'API répond
403 Aucune structure publique ou opérateur public associé au compte technique.
Annuaire
etablissement = client.annuaire.find_by_siret("70204275500240")
etablissement.denomination
etablissement.publique?
etablissement.gestion_engagement_juridique? # numéro d'engagement obligatoire ?
etablissement.lignes_annuaire.map(&:identifiant_routage)
unite = client.annuaire.find_by_siren("702042755")
Les lignes d'annuaire sont paginées :
lignes = client.annuaire.find_by_siret(siret, limite: 50, ignorer: 50).lignes_annuaire
lignes.nombre_total
lignes.suite? # reste-t-il des lignes au-delà de cette page ?
Recherche multi-critères — l'opérateur de comparaison (contient ou strict) est déduit du champ :
resultat = client.annuaire.search_siret(
filtres: { denomination: "MINISTERE", etatAdministratif: "A" },
tris: { siret: :ascendant },
limite: 20
)
resultat.nombre_total
resultat.map(&:siret)
Transmission d'une facture Factur-X
depot = client.flux.deposit_facturx("facture.pdf")
depot.uid # => "b18e3b6c-ccb7-4308-b527-35e5e6ee2145"
deposit_facturx accepte un chemin ou un objet IO, empaquette le PDF/A-3 dans l'archive tar.gz
attendue par le PPF, l'encode en base64 et calcule le checksum sha256.
La gem ne valide pas le contenu Factur-X : le fichier fourni doit déjà être conforme. Elle vérifie seulement, avant l'appel, qu'il s'agit bien d'un PDF et qu'un XML Factur-X y est joint — un garde-fou contre le mauvais fichier, dont le rejet ne serait connu que plusieurs minutes plus tard.
Le montant TTC déclaré par le XML embarqué est exposé, à confronter à ce que le logiciel hôte attend :
Cpro::Facturx.montant_total(File.binread("facture.pdf")) # => BigDecimal("1910.40")
Le même endpoint accepte trois autres types de flux, qui ne diffèrent que par leur code interface :
client.flux.deposit_ubl("facture.xml") # FSO3110A — facture UBL 2.1
client.flux.deposit_cdv("statut.xml") # FSO3300A — cycle de vie (CDAR D22B)
client.flux.deposit_ereporting("donnees.xml") # FSO6000A — e-reporting
Émettre un statut de cycle de vie n'est donc pas un appel de mise à jour : c'est un dépôt de flux. Le fichier attendu est un CDAR D22B conforme à l'Annexe A de la norme AFNOR XP Z12-012 ; la gem le transporte sans le fabriquer.
Le PPF répond 404 tant que le flux n'est pas traité. status renvoie donc nil dans ce cas, ce qui
rend le polling naturel ; status! lève une Cpro::NotFoundError si vous préférez l'exception.
statut = client.flux.status(depot)
statut&.recevable?
statut&.nom_flux # renseigné uniquement si le flux est recevable
statut&.date_maj_statut
Depuis Chorus Pro 5.2.2, un flux irrecevable expose son motif — auparavant le rejet était muet et ne pouvait se diagnostiquer que hors ligne :
statut.motifs_rejet.each do |motif|
motif.code # "IRR_SYNTAX"
motif.libelle # "Contrôle syntaxique des fichiers du flux"
motif.notes.map { |note| [note.sujet, note.contenu] }
end
Suivre la facture après le dépôt
Le statut du flux ne dit que la recevabilité de l'enveloppe. Le sort de la facture elle-même se lit
via l'API Recherche Factures, en repartant du nomFlux :
resultat = client.factures.search_by_nom_flux(statut) # accepte un StatutFlux ou une chaîne
facture = client.factures.find(resultat.first)
facture.statut # "MISE_A_DISPOSITION"
facture.encaissee? # un prédicat par statut, sur les 22 de l'énumération
facture.montant_total # BigDecimal
facture.historique.map { |changement| [changement.statut, changement.date] }
facture.motifs_rejet.map(&:to_s) # codes AFNOR et libellés, vides si aucun rejet
Les filtres de recherche sont plats, contrairement à ceux de l'Annuaire, et au moins un est
obligatoire. Un filtre inconnu lève une ArgumentError avant tout appel réseau :
client.factures.search(
filtres: { siretVendeur: "79614076743109", dateEmissionFactureDu: "2026-01-01" },
tris: { dateEmissionFacture: :descendant },
limite: 50, ignorer: 0
)
L'historique n'est renseigné que par find ; la recherche ne le retourne pas.
Deux points de vigilance. Un résultat vide est ambigu : l'API répond 204 aussi bien lorsque
aucune facture ne correspond que lorsque le compte technique n'est rattaché ni à la structure
émettrice ni à la destinataire. Et il n'existe aucun mécanisme de notification — ni webhook, ni
callback, et les statuts des factures G2B ne sont pas consultables sur le portail Chorus Pro : le
suivi passe nécessairement par une interrogation périodique de cette API.
Servir plusieurs entités
L'AIFE distingue trois types de raccordement API. Celui qui s'applique détermine le modèle à adopter.
Concentrateur (éditeur clients légers) — une solution web qui passe les appels pour le compte de
ses clients. « Seul le concentrateur a besoin d'une application sur PISTE ainsi que d'un
raccordement Chorus Pro », et chaque entité cliente dispose de son propre compte technique. Une
seule configuration suffit donc, et with_account porte l'entité — il partage le jeton OAuth et les
connexions HTTP :
client.with_account(compte_mairie).flux.deposit_facturx(facture_a)
client.with_account(compte_departement).flux.deposit_facturx(facture_b)
Éditeur clients lourds — la solution est installée chez chaque client, qui appelle depuis ses propres serveurs. Chaque client a alors sa propre application PISTE et son propre raccordement : il lui faut sa configuration, donc son client. Conservez-les plutôt que de les reconstruire à chaque requête, le cache de jeton vivant sur la configuration.
CLIENTS = entites.to_h do |entite|
configuration = Cpro::Configuration.new
configuration.client_id = entite.piste_client_id
configuration.client_secret = entite.piste_client_secret
configuration.account = Cpro::Account.new(login: entite.login, password: entite.password)
[entite.id, Cpro::Client.new(configuration)]
end
Dans les deux cas, l'AIFE recommande que chaque entité cliente crée elle-même son compte technique et en transmette les identifiants, plutôt que l'éditeur ne se rattache aux structures de ses clients — pratique explicitement déconseillée pour des raisons de sécurité.
Erreurs
Toutes les erreurs dérivent de Cpro::Error.
| Exception | Déclenchement |
|---|---|
Cpro::ConfigurationError |
configuration incomplète, compte technique manquant |
Cpro::RequestError |
400, 406, 415, 422 |
Cpro::AuthenticationError |
401, échec d'obtention du jeton |
Cpro::AuthorizationError |
403 — habilitations insuffisantes |
Cpro::NotFoundError |
404 |
Cpro::RateLimitError |
429 |
Cpro::ServerError |
5xx |
Les erreurs d'API exposent #status, #body et #correlation_id — ce dernier est l'identifiant
PISTE à fournir au support en cas d'incident.
Développement
bin/setup
bundle exec rake # tests unitaires + rubocop, aucun appel réseau
Les tests unitaires sont entièrement stubbés avec WebMock.
Conformité des messages CDAR
Les cycles de vie produits par la gem sont confrontés au schéma de la norme CDAR D22B et au Schematron des règles françaises BR-FR-CDV. Les ressources sont versionnées dans test/resources/cdar (Apache 2.0, dépôt FNFE France_RFE), donc ce contrôle tourne en CI sans installation ni accès réseau.
La validation de schéma passe par Nokogiri. Le Schematron étant en XSLT 2.0, il demande Saxon : à défaut, cette moitié du test se saute au lieu d'échouer.
brew install saxon # macOS
apt-get install -y libsaxonhe-java # Debian/Ubuntu
Tests sandbox
test/sandbox/ rejoue des échanges réellement enregistrés sur la sandbox PISTE, via des cassettes
VCR commitées. Hors ligne, sans identifiants, exécutable en CI :
bundle exec rake test:sandbox
Toute requête absente des cassettes fait échouer le test plutôt que de partir sur le réseau. Pour ré-enregistrer après une évolution de l'API :
CPRO_RECORD=1 bundle exec rake test:sandbox
Le mode enregistrement exige local/credentials.env et dépose réellement une facture dans la
sandbox. Les cassettes sont expurgées à l'écriture : identifiants PISTE et compte technique
remplacés par des jetons de substitution, en-têtes Authorization et cpro-account supprimés,
access_token écrasé. Un CPRO_CREDENTIALS_FILE permet de pointer un autre fichier d'identifiants.
Vérifié en sandbox
Chaîne complète validée le 14/08/2026 : dépôt d'un Factur-X accepté (uidFlux retourné) puis flux
RECEVABLE.
- Le checksum est bien le sha256 de la chaîne base64, pas des octets de l'archive — confirmé par un flux recevable, le PPF ayant validé l'intégrité.
- Le nom de l'archive
tar.gzn'est soumis à aucune règle ; celui du fichier qu'elle contient garde son extension d'origine. cpro-accountest exigé par l'Annuaire aussi, contrairement à son swagger.- Chorus Pro 5.2.2 (19/08/2026) ajoute
codeStatutetdetailStatutà la consultation de flux, et annonce un changement de format dunomFlux— n'écrivez donc rien qui présuppose son préfixe. - Les API datent en UTC sans marquer le fuseau (
2026-08-14T20:26:02.125919). La gem le rattrape : sans cela, la même réponse donnerait un instant différent selon le fuseau du serveur hôte. - La recevabilité porte sur l'enveloppe, pas sur le contenu métier : un flux dont le vendeur est
inconnu du destinataire ressort quand même
RECEVABLE. Le sort de la facture elle-même se lit via l'API Recherche Factures G2B, à partir dunomFlux.
Limite actuelle de l'Annuaire
En sandbox, find_by_siret et find_by_siren ne renvoient que le bloc lignesAnnuaire : siret,
denomination, adresse, uniteLegale et donneesB2gComplementaires ressortent à nil, y
compris pour la structure du compte appelant et même en précisant champs. Le mapping suit le
swagger et se remplira si l'implémentation se complète ; en attendant, l'information exploitable est
lignes_annuaire, qui porte les identifiantAdressage nécessaires à l'adressage de la facture.
client.annuaire.find_by_siret("71915767420316").lignes_annuaire.map(&:identifiant_adressage)
# => ["719157674_71915767420316",
# "719157674_71915767420316_FACTURES_PUBLIQUES",
# "719157674_71915767420316_SERVICE_PUBLIQUE_1_71915767420316"]
Licence
Disponible en open source sous les termes de la licence MIT.