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 :

  1. authentification OAuth2 auprès de PISTE ;
  2. recherche d'une structure dans l'annuaire par SIRET ou par SIREN ;
  3. transmission d'une facture Factur-X et suivi du flux.

Installation

gem "cpro-client"

Faraday est la seule dépendance d'exécution, de la version 0.9 à la version 2 incluse — la gem porte elle-même les middlewares JSON, que Faraday n'embarque qu'à partir de la 1.0. La suite est vérifiée sur 0.9.2, 1.10.4 et 2.8.1 ; le développement se fait sur la plus récente, et l'on teste un plancher avec gem "faraday", "0.9.2" ajouté au Gemfile avant bundle install.

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.(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

Consultation et recherche ne retournent pas les mêmes champs, et ne se remplacent pas. C'est le point le moins intuitif de cette API :

consultation (find_by_…) recherche (search_…)
identité — dénomination, adresse, état administratif
donneesB2gComplementaires — engagement juridique, gestion du code service…
lignesAnnuaire — les identifiants d'adressage

Il faut donc les deux appels pour avoir toute l'information d'une structure. La gem ne les enchaîne pas à votre place : c'est un état de la plateforme, pas une règle métier, et il changera.

Consultation

Elle sert les lignes d'annuaire, c'est-à-dire les identifiants nécessaires à l'adressage d'une facture :

etablissement = client.annuaire.find_by_siret("70204275500240")
etablissement.lignes_annuaire.map(&:identifiant_adressage)
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 ?

Une structure sans ligne d'annuaire à la maille SIRET répond 404 en consultation par SIRET, tout en étant présente dans l'annuaire : c'est le cas des structures privées, dont l'adressage se fait à la maille SIREN. Cherchez-les alors par search_siret, ou consultez leur SIREN.

Recherche

Elle sert l'identité et les données B2G :

resultat = client.annuaire.search_siret(
  filtres: { denomination: "MINISTERE", etatAdministratif: "A" },
  tris: { siret: :ascendant },
  limite: 20
)

resultat.nombre_total     # nil si l'API ne le renseigne pas — ce n'est pas zéro
resultat.first.denomination
resultat.first.gestion_engagement_juridique?

L'opérateur de comparaison est déduit du champ. Les libellés se cherchent en contient, les identifiants en strict : le swagger déclare contient sur siret et siren, mais le serveur refuse cette forme par un 400 Failed to read HTTP message. Un filtre inconnu lève une ArgumentError avant tout appel réseau.

client.annuaire.search_siret(filtres: { siret: "70204275500240" })   # strict, implicitement

Codes routage

Un code routage est le troisième niveau de l'annuaire — le service, sous l'établissement. Sa recherche est le seul accès à son libellé lisible et à son engagement juridique : ni la consultation de l'établissement ni celle du code routage lui-même ne les servent.

codes = client.annuaire.search_code_routage(filtres: { siret: "71915767420316" })

codes.map(&:to_s)                          # => ["FACTURES_PUBLIQUES — Service des factures publiques", …]
codes.first.libelle                        # "Service des factures publiques"
codes.first.gestion_engagement_juridique?  # numéro d'engagement obligatoire pour ce service ?
codes.first.actif?

Filtrez sur siret, faute de quoi la réponse balaie tout l'annuaire.

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.(compte_mairie).flux.deposit_facturx(facture_a)
client.(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.       = Cpro::Account.new(login: entite., 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.gz n'est soumis à aucune règle ; celui du fichier qu'elle contient garde son extension d'origine.
  • cpro-account est exigé par l'Annuaire aussi, contrairement à son swagger.
  • Chorus Pro 5.2.2 (19/08/2026) ajoute codeStatut et detailStatut à la consultation de flux, et annonce un changement de format du nomFlux — 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 du nomFlux.
  • Les filtres siret et siren des recherches d'annuaire n'acceptent que l'opérateur strict, alors que le swagger déclare contient : cette forme est refusée par un 400 Failed to read HTTP message, une erreur de désérialisation levée avant tout traitement.
  • La recherche d'annuaire est plus riche que la consultation, et réciproquement : identité et données B2G d'un côté, lignes d'annuaire de l'autre, sans recouvrement.
  • Le libellé d'un code routage et son gestionEngagementJuridique ne sont servis que par search_code_routage. La consultation unitaire d'un code routage retourne moins que find_by_siret, ce qui est la raison pour laquelle la gem ne l'expose pas.

Ce que la consultation ne dit pas

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.

Ce n'est pas une limite du jeu de données : la recherche, elle, sert ces champs — mesuré sur la même structure, au même instant. C'est la consultation qui est incomplète. Passez donc par search_siret pour l'identité, et par la consultation pour les identifiants d'adressage :

client.annuaire.find_by_siret("71915767420316").lignes_annuaire.map(&:identifiant_adressage)
# => ["719157674_71915767420316",
#     "719157674_71915767420316_FACTURES_PUBLIQUES",
#     "719157674_71915767420316_SERVICE_PUBLIQUE_1_71915767420316"]

client.annuaire.search_siret(filtres: { siret: "71915767420316" }).first.denomination
# => "Destinataire 71915767420316"

Adressage des structures privées

En G2B, le destinataire est une entreprise privée. Sur le matelas de qualification, ces structures n'ont aucune ligne d'annuaire à la maille SIRET : leur seul identifiant d'adressage est le SIREN nu, et leur statutPlateforme vaut « Pas de plateforme ». Leur SIRET reste consultable par recherche, mais pas par find_by_siret, qui répond 404.

client.annuaire.find_by_siren("474775418").lignes_annuaire.map(&:identifiant_adressage)
# => ["474775418"]

C'est cet identifiant qui doit alimenter BT-49 sur la facture et MDT-73 sur le cycle de vie. Vérifiez plateforme_active? avant de compter sur un acheminement.

Licence

Disponible en open source sous les termes de la licence MIT.