factpulse 1.0.6__py3-none-any.whl → 2.0.37__py3-none-any.whl

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.

Potentially problematic release.


This version of factpulse might be problematic. Click here for more details.

Files changed (147) hide show
  1. factpulse/__init__.py +54 -54
  2. factpulse/api/__init__.py +1 -2
  3. factpulse/api/afnorpdppa_api.py +552 -2
  4. factpulse/api/afnorpdppa_directory_service_api.py +4312 -65
  5. factpulse/api/afnorpdppa_flow_service_api.py +1 -1
  6. factpulse/api/chorus_pro_api.py +152 -194
  7. factpulse/api/sant_api.py +246 -1
  8. factpulse/api/traitement_facture_api.py +25 -27
  9. factpulse/api/utilisateur_api.py +1 -1
  10. factpulse/api/vrification_pdfxml_api.py +1719 -0
  11. factpulse/api_client.py +5 -5
  12. factpulse/configuration.py +5 -3
  13. factpulse/exceptions.py +7 -4
  14. factpulse/models/__init__.py +26 -25
  15. factpulse/models/adresse_electronique.py +1 -1
  16. factpulse/models/adresse_postale.py +1 -1
  17. factpulse/models/{body_ajouter_fichier_api_v1_chorus_pro_transverses_ajouter_fichier_post.py → api_error.py} +24 -24
  18. factpulse/models/{body_completer_facture_api_v1_chorus_pro_factures_completer_post.py → bounding_box_schema.py} +23 -27
  19. factpulse/models/cadre_de_facturation.py +11 -3
  20. factpulse/models/categorie_tva.py +11 -11
  21. factpulse/models/certificate_info_response.py +1 -1
  22. factpulse/models/champ_verifie_schema.py +129 -0
  23. factpulse/models/chorus_pro_credentials.py +1 -1
  24. factpulse/models/code_cadre_facturation.py +2 -2
  25. factpulse/models/code_raison_reduction.py +9 -9
  26. factpulse/models/consulter_facture_request.py +1 -1
  27. factpulse/models/consulter_facture_response.py +1 -1
  28. factpulse/models/consulter_structure_request.py +1 -1
  29. factpulse/models/consulter_structure_response.py +1 -1
  30. factpulse/models/credentials_afnor.py +1 -1
  31. factpulse/models/credentials_chorus_pro.py +1 -1
  32. factpulse/models/destinataire.py +16 -2
  33. factpulse/models/destination.py +1 -1
  34. factpulse/models/destination_afnor.py +1 -1
  35. factpulse/models/destination_chorus_pro.py +1 -1
  36. factpulse/models/{quota_info.py → dimension_page_schema.py} +12 -18
  37. factpulse/models/direction_flux.py +1 -1
  38. factpulse/models/donnees_facture_simplifiees.py +1 -1
  39. factpulse/models/error_level.py +37 -0
  40. factpulse/models/error_source.py +43 -0
  41. factpulse/models/{facture_enrichie_info_output.py → facture_enrichie_info.py} +4 -4
  42. factpulse/models/facture_entrante.py +196 -0
  43. factpulse/models/facture_factur_x.py +12 -2
  44. factpulse/models/flux_resume.py +1 -1
  45. factpulse/models/format_facture.py +38 -0
  46. factpulse/models/format_sortie.py +1 -1
  47. factpulse/models/fournisseur.py +9 -2
  48. factpulse/models/fournisseur_entrant.py +144 -0
  49. factpulse/models/generate_certificate_request.py +1 -1
  50. factpulse/models/generate_certificate_response.py +1 -1
  51. factpulse/models/http_validation_error.py +1 -1
  52. factpulse/models/information_signature_api.py +1 -1
  53. factpulse/models/ligne_de_poste.py +7 -12
  54. factpulse/models/ligne_de_poste_montant_remise_ht.py +2 -2
  55. factpulse/models/ligne_de_poste_taux_tva_manuel.py +2 -2
  56. factpulse/models/ligne_de_tva.py +24 -10
  57. factpulse/models/mode_depot.py +1 -1
  58. factpulse/models/mode_paiement.py +1 -1
  59. factpulse/models/{montantapayer.py → montant_a_payer.py} +6 -6
  60. factpulse/models/{montantbaseht.py → montant_base_ht.py} +6 -6
  61. factpulse/models/montant_ht_total.py +4 -4
  62. factpulse/models/{montant_total_montant_remise_globale_ttc.py → montant_remise_globale_ttc.py} +7 -13
  63. factpulse/models/montant_total.py +16 -21
  64. factpulse/models/montant_total_acompte.py +2 -2
  65. factpulse/models/{ligne_de_poste_montant_total_ligne_ht.py → montant_total_ligne_ht.py} +7 -13
  66. factpulse/models/montant_ttc_total.py +4 -4
  67. factpulse/models/montant_tva.py +2 -2
  68. factpulse/models/{montanttva1.py → montant_tva_ligne.py} +7 -7
  69. factpulse/models/{montantttctotal.py → montant_tva_total.py} +7 -7
  70. factpulse/models/{montantunitaireht.py → montant_unitaire_ht.py} +5 -5
  71. factpulse/models/nature_operation.py +49 -0
  72. factpulse/models/{body_valideur_rechercher_factures_api_v1_chorus_pro_factures_valideur_rechercher_post.py → note.py} +14 -24
  73. factpulse/models/{utilisateur.py → note_obligatoire_schema.py} +40 -44
  74. factpulse/models/obtenir_id_chorus_pro_request.py +1 -1
  75. factpulse/models/obtenir_id_chorus_pro_response.py +1 -1
  76. factpulse/models/options_processing.py +5 -14
  77. factpulse/models/parametres_signature.py +1 -1
  78. factpulse/models/parametres_structure.py +1 -1
  79. factpulse/models/pdf_factur_x_info.py +1 -1
  80. factpulse/models/pdp_credentials.py +10 -3
  81. factpulse/models/piece_jointe_complementaire.py +1 -1
  82. factpulse/models/profil_api.py +1 -1
  83. factpulse/models/profil_flux.py +1 -1
  84. factpulse/models/quantite.py +1 -1
  85. factpulse/models/rechercher_services_response.py +1 -1
  86. factpulse/models/rechercher_structure_request.py +1 -1
  87. factpulse/models/rechercher_structure_response.py +1 -1
  88. factpulse/models/references.py +1 -1
  89. factpulse/models/reponse_healthcheck_afnor.py +1 -1
  90. factpulse/models/reponse_recherche_flux.py +1 -1
  91. factpulse/models/reponse_soumission_flux.py +1 -1
  92. factpulse/models/reponse_tache.py +1 -1
  93. factpulse/models/reponse_validation_erreur.py +1 -1
  94. factpulse/models/reponse_validation_succes.py +1 -1
  95. factpulse/models/reponse_verification_succes.py +135 -0
  96. factpulse/models/requete_recherche_flux.py +1 -1
  97. factpulse/models/requete_soumission_flux.py +1 -1
  98. factpulse/models/resultat_afnor.py +1 -1
  99. factpulse/models/resultat_chorus_pro.py +1 -1
  100. factpulse/models/resultat_validation_pdfapi.py +1 -1
  101. factpulse/models/scheme_id.py +7 -7
  102. factpulse/models/service_structure.py +1 -1
  103. factpulse/models/signature_info.py +1 -1
  104. factpulse/models/soumettre_facture_complete_request.py +1 -1
  105. factpulse/models/soumettre_facture_complete_response.py +4 -4
  106. factpulse/models/soumettre_facture_request.py +19 -7
  107. factpulse/models/soumettre_facture_response.py +1 -1
  108. factpulse/models/statut_acquittement.py +1 -1
  109. factpulse/models/statut_celery.py +40 -0
  110. factpulse/models/statut_champ_api.py +40 -0
  111. factpulse/models/statut_facture.py +1 -1
  112. factpulse/models/statut_tache.py +7 -9
  113. factpulse/models/structure_info.py +1 -1
  114. factpulse/models/syntaxe_flux.py +1 -1
  115. factpulse/models/tauxmanuel.py +2 -2
  116. factpulse/models/type_document.py +40 -0
  117. factpulse/models/type_facture.py +1 -1
  118. factpulse/models/type_flux.py +1 -1
  119. factpulse/models/type_tva.py +1 -1
  120. factpulse/models/unite.py +1 -1
  121. factpulse/models/validation_error.py +1 -1
  122. factpulse/models/{body_rechercher_factures_fournisseur_api_v1_chorus_pro_factures_rechercher_fournisseur_post.py → validation_error_detail.py} +27 -24
  123. factpulse/models/validation_error_loc_inner.py +1 -1
  124. factpulse/rest.py +8 -3
  125. factpulse-2.0.37.dist-info/METADATA +292 -0
  126. factpulse-2.0.37.dist-info/RECORD +134 -0
  127. factpulse-2.0.37.dist-info/top_level.txt +2 -0
  128. factpulse_helpers/__init__.py +96 -0
  129. factpulse_helpers/client.py +1887 -0
  130. factpulse_helpers/exceptions.py +253 -0
  131. factpulse/api/processing_endpoints_unifis_api.py +0 -592
  132. factpulse/api/signature_lectronique_api.py +0 -1358
  133. factpulse/models/body_lister_services_structure_api_v1_chorus_pro_structures_id_structure_cpp_services_get.py +0 -102
  134. factpulse/models/body_rechercher_factures_destinataire_api_v1_chorus_pro_factures_rechercher_destinataire_post.py +0 -104
  135. factpulse/models/body_recycler_facture_api_v1_chorus_pro_factures_recycler_post.py +0 -104
  136. factpulse/models/body_telecharger_groupe_factures_api_v1_chorus_pro_factures_telecharger_groupe_post.py +0 -104
  137. factpulse/models/body_traiter_facture_recue_api_v1_chorus_pro_factures_traiter_facture_recue_post.py +0 -104
  138. factpulse/models/body_valideur_consulter_facture_api_v1_chorus_pro_factures_valideur_consulter_post.py +0 -104
  139. factpulse/models/body_valideur_traiter_facture_api_v1_chorus_pro_factures_valideur_traiter_post.py +0 -104
  140. factpulse/models/facture_enrichie_info_input.py +0 -123
  141. factpulse/models/montanthttotal.py +0 -139
  142. factpulse/models/montanttva.py +0 -139
  143. factpulse-1.0.6.dist-info/METADATA +0 -182
  144. factpulse-1.0.6.dist-info/RECORD +0 -131
  145. factpulse-1.0.6.dist-info/top_level.txt +0 -1
  146. {factpulse-1.0.6.dist-info → factpulse-2.0.37.dist-info}/WHEEL +0 -0
  147. {factpulse-1.0.6.dist-info → factpulse-2.0.37.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1887 @@
1
+ """Client simplifié pour l'API FactPulse avec authentification JWT et polling intégrés."""
2
+ import base64
3
+ import json
4
+ import logging
5
+ import time
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timedelta
8
+ from decimal import Decimal
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Tuple, Union
11
+
12
+ import requests
13
+
14
+ import factpulse
15
+ from factpulse import ApiClient, Configuration, TraitementFactureApi
16
+
17
+ from .exceptions import (
18
+ FactPulseAuthError,
19
+ FactPulsePollingTimeout,
20
+ FactPulseValidationError,
21
+ ValidationErrorDetail,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # =============================================================================
28
+ # JSON Encoder pour Decimal et autres types non sérialisables
29
+ # =============================================================================
30
+
31
+ class DecimalEncoder(json.JSONEncoder):
32
+ """Encoder JSON personnalisé qui gère les Decimal et autres types Python."""
33
+
34
+ def default(self, obj):
35
+ if isinstance(obj, Decimal):
36
+ # Convertir en string pour préserver la précision monétaire
37
+ return str(obj)
38
+ if hasattr(obj, "isoformat"):
39
+ # datetime, date, time
40
+ return obj.isoformat()
41
+ if hasattr(obj, "to_dict"):
42
+ # Modèles Pydantic ou dataclasses avec to_dict
43
+ return obj.to_dict()
44
+ return super().default(obj)
45
+
46
+
47
+ def json_dumps_safe(data: Any, **kwargs) -> str:
48
+ """Sérialise en JSON en gérant les Decimal et autres types Python.
49
+
50
+ Args:
51
+ data: Données à sérialiser (dict, list, etc.)
52
+ **kwargs: Arguments supplémentaires pour json.dumps
53
+
54
+ Returns:
55
+ String JSON
56
+
57
+ Example:
58
+ >>> from decimal import Decimal
59
+ >>> json_dumps_safe({"montant": Decimal("1234.56")})
60
+ '{"montant": "1234.56"}'
61
+ """
62
+ kwargs.setdefault("ensure_ascii", False)
63
+ kwargs.setdefault("cls", DecimalEncoder)
64
+ return json.dumps(data, **kwargs)
65
+
66
+
67
+ # =============================================================================
68
+ # Credentials dataclasses - pour une configuration simplifiée
69
+ # =============================================================================
70
+
71
+ @dataclass
72
+ class ChorusProCredentials:
73
+ """Credentials Chorus Pro pour le mode Zero-Trust.
74
+
75
+ Ces credentials sont passés dans chaque requête et ne sont jamais stockés côté serveur.
76
+
77
+ Attributes:
78
+ piste_client_id: Client ID PISTE (portail API gouvernement)
79
+ piste_client_secret: Client Secret PISTE
80
+ chorus_pro_login: Login Chorus Pro
81
+ chorus_pro_password: Mot de passe Chorus Pro
82
+ sandbox: True pour l'environnement sandbox, False pour production
83
+ """
84
+ piste_client_id: str
85
+ piste_client_secret: str
86
+ chorus_pro_login: str
87
+ chorus_pro_password: str
88
+ sandbox: bool = True
89
+
90
+ def to_dict(self) -> Dict[str, Any]:
91
+ """Convertit en dictionnaire pour l'API."""
92
+ return {
93
+ "piste_client_id": self.piste_client_id,
94
+ "piste_client_secret": self.piste_client_secret,
95
+ "chorus_pro_login": self.chorus_pro_login,
96
+ "chorus_pro_password": self.chorus_pro_password,
97
+ "sandbox": self.sandbox,
98
+ }
99
+
100
+
101
+ @dataclass
102
+ class AFNORCredentials:
103
+ """Credentials AFNOR PDP pour le mode Zero-Trust.
104
+
105
+ Ces credentials sont passés dans chaque requête et ne sont jamais stockés côté serveur.
106
+ L'API FactPulse utilise ces credentials pour s'authentifier auprès de la PDP AFNOR
107
+ et obtenir un token OAuth2 spécifique.
108
+
109
+ Attributes:
110
+ flow_service_url: URL du Flow Service de la PDP (ex: https://api.pdp.fr/flow/v1)
111
+ token_url: URL du serveur OAuth2 de la PDP (ex: https://auth.pdp.fr/oauth/token)
112
+ client_id: Client ID OAuth2 de la PDP
113
+ client_secret: Client Secret OAuth2 de la PDP
114
+ directory_service_url: URL du Directory Service (optionnel, déduit de flow_service_url)
115
+ """
116
+ flow_service_url: str
117
+ token_url: str
118
+ client_id: str
119
+ client_secret: str
120
+ directory_service_url: Optional[str] = None
121
+
122
+ def to_dict(self) -> Dict[str, Any]:
123
+ """Convertit en dictionnaire pour l'API."""
124
+ result = {
125
+ "flow_service_url": self.flow_service_url,
126
+ "token_url": self.token_url,
127
+ "client_id": self.client_id,
128
+ "client_secret": self.client_secret,
129
+ }
130
+ if self.directory_service_url:
131
+ result["directory_service_url"] = self.directory_service_url
132
+ return result
133
+
134
+
135
+ # =============================================================================
136
+ # Helpers pour les types anyOf - évite la verbosité des wrappers générés
137
+ # =============================================================================
138
+
139
+ def montant(value: Union[str, float, int, Decimal, None]) -> str:
140
+ """Convertit une valeur en string de montant pour l'API.
141
+
142
+ L'API FactPulse accepte les montants comme strings ou floats.
143
+ Cette fonction normalise en string pour garantir la précision monétaire.
144
+ """
145
+ if value is None:
146
+ return "0.00"
147
+ if isinstance(value, Decimal):
148
+ return f"{value:.2f}"
149
+ if isinstance(value, (int, float)):
150
+ return f"{value:.2f}"
151
+ if isinstance(value, str):
152
+ return value
153
+ return "0.00"
154
+
155
+
156
+ def montant_total(
157
+ ht: Union[str, float, int, Decimal],
158
+ tva: Union[str, float, int, Decimal],
159
+ ttc: Union[str, float, int, Decimal],
160
+ a_payer: Union[str, float, int, Decimal],
161
+ remise_ttc: Union[str, float, int, Decimal, None] = None,
162
+ motif_remise: Optional[str] = None,
163
+ acompte: Union[str, float, int, Decimal, None] = None,
164
+ ) -> Dict[str, Any]:
165
+ """Crée un objet MontantTotal simplifié.
166
+
167
+ Évite d'avoir à utiliser les wrappers MontantHtTotal, MontantTvaTotal, etc.
168
+ """
169
+ result = {
170
+ "montantHtTotal": montant(ht),
171
+ "montantTva": montant(tva),
172
+ "montantTtcTotal": montant(ttc),
173
+ "montantAPayer": montant(a_payer),
174
+ }
175
+ if remise_ttc is not None:
176
+ result["montantRemiseGlobaleTtc"] = montant(remise_ttc)
177
+ if motif_remise is not None:
178
+ result["motifRemiseGlobaleTtc"] = motif_remise
179
+ if acompte is not None:
180
+ result["acompte"] = montant(acompte)
181
+ return result
182
+
183
+
184
+ def ligne_de_poste(
185
+ numero: int,
186
+ denomination: str,
187
+ quantite: Union[str, float, int, Decimal],
188
+ montant_unitaire_ht: Union[str, float, int, Decimal],
189
+ montant_total_ligne_ht: Union[str, float, int, Decimal],
190
+ taux_tva: Optional[str] = None,
191
+ taux_tva_manuel: Union[str, float, int, Decimal, None] = "20.00",
192
+ categorie_tva: str = "S",
193
+ unite: str = "FORFAIT",
194
+ reference: Optional[str] = None,
195
+ montant_remise_ht: Union[str, float, int, Decimal, None] = None,
196
+ code_raison_reduction: Optional[str] = None,
197
+ raison_reduction: Optional[str] = None,
198
+ date_debut_periode: Optional[str] = None,
199
+ date_fin_periode: Optional[str] = None,
200
+ ) -> Dict[str, Any]:
201
+ """Crée une ligne de poste pour l'API FactPulse.
202
+
203
+ Les clés JSON sont en camelCase (convention API FactPulse).
204
+ Les champs correspondent exactement à LigneDePoste dans models.py.
205
+
206
+ Pour le taux de TVA, vous pouvez utiliser soit:
207
+ - taux_tva: Code prédéfini (ex: "TVA20", "TVA10", "TVA5.5")
208
+ - taux_tva_manuel: Valeur numérique (ex: "20.00", 20, 20.0)
209
+
210
+ Args:
211
+ numero: Numéro de la ligne
212
+ denomination: Libellé du produit/service
213
+ quantite: Quantité
214
+ montant_unitaire_ht: Prix unitaire HT
215
+ montant_total_ligne_ht: Montant total HT de la ligne
216
+ taux_tva: Code TVA prédéfini (ex: "TVA20") - optionnel
217
+ taux_tva_manuel: Taux de TVA en valeur (défaut: "20.00") - utilisé si taux_tva non fourni
218
+ categorie_tva: Catégorie TVA - S (standard), Z (zéro), E (exonéré), AE (autoliquidation), K (intracommunautaire)
219
+ unite: Unité de facturation (défaut: "FORFAIT")
220
+ reference: Référence article
221
+ montant_remise_ht: Montant de remise HT (optionnel)
222
+ code_raison_reduction: Code raison de la réduction
223
+ raison_reduction: Description textuelle de la réduction
224
+ date_debut_periode: Date début période de facturation (YYYY-MM-DD)
225
+ date_fin_periode: Date fin période de facturation (YYYY-MM-DD)
226
+ """
227
+ result = {
228
+ "numero": numero,
229
+ "denomination": denomination,
230
+ "quantite": montant(quantite),
231
+ "montantUnitaireHt": montant(montant_unitaire_ht),
232
+ "montantTotalLigneHt": montant(montant_total_ligne_ht),
233
+ "categorieTva": categorie_tva,
234
+ "unite": unite,
235
+ }
236
+ # Soit taux_tva (code) soit taux_tva_manuel (valeur)
237
+ if taux_tva is not None:
238
+ result["tauxTva"] = taux_tva
239
+ elif taux_tva_manuel is not None:
240
+ result["tauxTvaManuel"] = montant(taux_tva_manuel)
241
+ if reference is not None:
242
+ result["reference"] = reference
243
+ if montant_remise_ht is not None:
244
+ result["montantRemiseHt"] = montant(montant_remise_ht)
245
+ if code_raison_reduction is not None:
246
+ result["codeRaisonReduction"] = code_raison_reduction
247
+ if raison_reduction is not None:
248
+ result["raisonReduction"] = raison_reduction
249
+ if date_debut_periode is not None:
250
+ result["dateDebutPeriode"] = date_debut_periode
251
+ if date_fin_periode is not None:
252
+ result["dateFinPeriode"] = date_fin_periode
253
+ return result
254
+
255
+
256
+ def ligne_de_tva(
257
+ montant_base_ht: Union[str, float, int, Decimal],
258
+ montant_tva: Union[str, float, int, Decimal],
259
+ taux: Optional[str] = None,
260
+ taux_manuel: Union[str, float, int, Decimal, None] = "20.00",
261
+ categorie: str = "S",
262
+ ) -> Dict[str, Any]:
263
+ """Crée une ligne de TVA pour l'API FactPulse.
264
+
265
+ Les clés JSON sont en camelCase (convention API FactPulse).
266
+ Les champs correspondent exactement à LigneDeTVA dans models.py.
267
+
268
+ Pour le taux de TVA, vous pouvez utiliser soit:
269
+ - taux: Code prédéfini (ex: "TVA20", "TVA10", "TVA5.5")
270
+ - taux_manuel: Valeur numérique (ex: "20.00", 20, 20.0)
271
+
272
+ Args:
273
+ montant_base_ht: Montant de la base HT
274
+ montant_tva: Montant de la TVA
275
+ taux: Code TVA prédéfini (ex: "TVA20") - optionnel
276
+ taux_manuel: Taux de TVA en valeur (défaut: "20.00") - utilisé si taux non fourni
277
+ categorie: Catégorie de TVA (défaut: "S" pour standard)
278
+ """
279
+ result = {
280
+ "montantBaseHt": montant(montant_base_ht),
281
+ "montantTva": montant(montant_tva),
282
+ "categorie": categorie,
283
+ }
284
+ # Soit taux (code) soit taux_manuel (valeur)
285
+ if taux is not None:
286
+ result["taux"] = taux
287
+ elif taux_manuel is not None:
288
+ result["tauxManuel"] = montant(taux_manuel)
289
+ return result
290
+
291
+
292
+ def adresse_postale(
293
+ ligne1: str,
294
+ code_postal: str,
295
+ ville: str,
296
+ pays: str = "FR",
297
+ ligne2: Optional[str] = None,
298
+ ligne3: Optional[str] = None,
299
+ ) -> Dict[str, Any]:
300
+ """Crée une adresse postale pour l'API FactPulse.
301
+
302
+ Args:
303
+ ligne1: Première ligne d'adresse (numéro, rue)
304
+ code_postal: Code postal
305
+ ville: Nom de la ville
306
+ pays: Code pays ISO (défaut: "FR")
307
+ ligne2: Deuxième ligne d'adresse (optionnel)
308
+ ligne3: Troisième ligne d'adresse (optionnel)
309
+
310
+ Example:
311
+ >>> adresse = adresse_postale("123 rue Example", "75001", "Paris")
312
+ """
313
+ result = {
314
+ "ligneUn": ligne1,
315
+ "codePostal": code_postal,
316
+ "nomVille": ville,
317
+ "paysCodeIso": pays,
318
+ }
319
+ if ligne2:
320
+ result["ligneDeux"] = ligne2
321
+ if ligne3:
322
+ result["ligneTrois"] = ligne3
323
+ return result
324
+
325
+
326
+ def adresse_electronique(
327
+ identifiant: str,
328
+ scheme_id: str = "0009",
329
+ ) -> Dict[str, Any]:
330
+ """Crée une adresse électronique pour l'API FactPulse.
331
+
332
+ Args:
333
+ identifiant: Identifiant de l'adresse (SIRET, SIREN, etc.)
334
+ scheme_id: Schéma d'identification (défaut: "0009" pour SIREN)
335
+ - "0009": SIREN
336
+ - "0088": EAN
337
+ - "0096": DUNS
338
+ - "0130": Codification propre
339
+ - "0225": FR - SIRET (schéma français)
340
+
341
+ Example:
342
+ >>> adresse = adresse_electronique("12345678901234", "0225") # SIRET
343
+ """
344
+ return {
345
+ "identifiant": identifiant,
346
+ "schemeId": scheme_id,
347
+ }
348
+
349
+
350
+ def fournisseur(
351
+ nom: str,
352
+ siret: str,
353
+ adresse_ligne1: str,
354
+ code_postal: str,
355
+ ville: str,
356
+ id_fournisseur: int = 0,
357
+ siren: Optional[str] = None,
358
+ numero_tva_intra: Optional[str] = None,
359
+ iban: Optional[str] = None,
360
+ pays: str = "FR",
361
+ adresse_ligne2: Optional[str] = None,
362
+ code_service: Optional[int] = None,
363
+ code_coordonnees_bancaires: Optional[int] = None,
364
+ ) -> Dict[str, Any]:
365
+ """Crée un fournisseur (émetteur de la facture) pour l'API FactPulse.
366
+
367
+ Cette fonction simplifie la création d'un fournisseur en générant automatiquement:
368
+ - L'adresse postale structurée
369
+ - L'adresse électronique (basée sur le SIRET)
370
+ - Le SIREN (extrait du SIRET si non fourni)
371
+ - Le numéro de TVA intracommunautaire (calculé depuis le SIREN si non fourni)
372
+
373
+ Args:
374
+ nom: Raison sociale / dénomination
375
+ siret: Numéro SIRET (14 chiffres)
376
+ adresse_ligne1: Première ligne d'adresse
377
+ code_postal: Code postal
378
+ ville: Ville
379
+ id_fournisseur: ID Chorus Pro du fournisseur (défaut: 0)
380
+ siren: Numéro SIREN (9 chiffres) - calculé depuis SIRET si absent
381
+ numero_tva_intra: Numéro TVA intracommunautaire - calculé si absent
382
+ iban: IBAN pour le paiement
383
+ pays: Code pays ISO (défaut: "FR")
384
+ adresse_ligne2: Deuxième ligne d'adresse (optionnel)
385
+ code_service: ID du service fournisseur Chorus Pro (optionnel)
386
+ code_coordonnees_bancaires: Code coordonnées bancaires Chorus Pro (optionnel)
387
+
388
+ Returns:
389
+ Dict prêt à être utilisé dans une facture
390
+
391
+ Example:
392
+ >>> f = fournisseur(
393
+ ... nom="Ma Société SAS",
394
+ ... siret="12345678900001",
395
+ ... adresse_ligne1="123 Rue de la République",
396
+ ... code_postal="75001",
397
+ ... ville="Paris",
398
+ ... iban="FR7630006000011234567890189",
399
+ ... )
400
+ """
401
+ # Auto-calcul SIREN depuis SIRET
402
+ if not siren and len(siret) == 14:
403
+ siren = siret[:9]
404
+
405
+ # Auto-calcul TVA intracommunautaire française
406
+ if not numero_tva_intra and siren and len(siren) == 9:
407
+ # Clé TVA = (12 + 3 * (SIREN % 97)) % 97
408
+ try:
409
+ cle = (12 + 3 * (int(siren) % 97)) % 97
410
+ numero_tva_intra = f"FR{cle:02d}{siren}"
411
+ except ValueError:
412
+ pass # SIREN non numérique, on skip
413
+
414
+ result: Dict[str, Any] = {
415
+ "nom": nom,
416
+ "idFournisseur": id_fournisseur,
417
+ "siret": siret,
418
+ "adresseElectronique": adresse_electronique(siret, "0225"),
419
+ "adressePostale": adresse_postale(adresse_ligne1, code_postal, ville, pays, adresse_ligne2),
420
+ }
421
+
422
+ if siren:
423
+ result["siren"] = siren
424
+ if numero_tva_intra:
425
+ result["numeroTvaIntra"] = numero_tva_intra
426
+ if iban:
427
+ result["iban"] = iban
428
+ if code_service:
429
+ result["idServiceFournisseur"] = code_service
430
+ if code_coordonnees_bancaires:
431
+ result["codeCoordonnesBancairesFournisseur"] = code_coordonnees_bancaires
432
+
433
+ return result
434
+
435
+
436
+ def destinataire(
437
+ nom: str,
438
+ siret: str,
439
+ adresse_ligne1: str,
440
+ code_postal: str,
441
+ ville: str,
442
+ siren: Optional[str] = None,
443
+ pays: str = "FR",
444
+ adresse_ligne2: Optional[str] = None,
445
+ code_service_executant: Optional[str] = None,
446
+ ) -> Dict[str, Any]:
447
+ """Crée un destinataire (client de la facture) pour l'API FactPulse.
448
+
449
+ Cette fonction simplifie la création d'un destinataire en générant automatiquement:
450
+ - L'adresse postale structurée
451
+ - L'adresse électronique (basée sur le SIRET)
452
+ - Le SIREN (extrait du SIRET si non fourni)
453
+
454
+ Args:
455
+ nom: Raison sociale / dénomination
456
+ siret: Numéro SIRET (14 chiffres)
457
+ adresse_ligne1: Première ligne d'adresse
458
+ code_postal: Code postal
459
+ ville: Ville
460
+ siren: Numéro SIREN (9 chiffres) - calculé depuis SIRET si absent
461
+ pays: Code pays ISO (défaut: "FR")
462
+ adresse_ligne2: Deuxième ligne d'adresse (optionnel)
463
+ code_service_executant: Code du service destinataire (optionnel)
464
+
465
+ Returns:
466
+ Dict prêt à être utilisé dans une facture
467
+
468
+ Example:
469
+ >>> d = destinataire(
470
+ ... nom="Client SARL",
471
+ ... siret="98765432109876",
472
+ ... adresse_ligne1="456 Avenue des Champs",
473
+ ... code_postal="69001",
474
+ ... ville="Lyon",
475
+ ... )
476
+ """
477
+ # Auto-calcul SIREN depuis SIRET
478
+ if not siren and len(siret) == 14:
479
+ siren = siret[:9]
480
+
481
+ result: Dict[str, Any] = {
482
+ "nom": nom,
483
+ "siret": siret,
484
+ "adresseElectronique": adresse_electronique(siret, "0225"),
485
+ "adressePostale": adresse_postale(adresse_ligne1, code_postal, ville, pays, adresse_ligne2),
486
+ }
487
+
488
+ if siren:
489
+ result["siren"] = siren
490
+ if code_service_executant:
491
+ result["codeServiceExecutant"] = code_service_executant
492
+
493
+ return result
494
+
495
+
496
+ class FactPulseClient:
497
+ """Client simplifié pour l'API FactPulse.
498
+
499
+ Gère l'authentification JWT, le polling des tâches asynchrones,
500
+ et permet de configurer les credentials Chorus Pro / AFNOR à l'initialisation.
501
+ """
502
+
503
+ DEFAULT_API_URL = "https://factpulse.fr"
504
+ DEFAULT_POLLING_INTERVAL = 2000 # ms
505
+ DEFAULT_POLLING_TIMEOUT = 120000 # ms
506
+ DEFAULT_MAX_RETRIES = 1
507
+
508
+ def __init__(
509
+ self,
510
+ email: str,
511
+ password: str,
512
+ api_url: Optional[str] = None,
513
+ client_uid: Optional[str] = None,
514
+ chorus_credentials: Optional[ChorusProCredentials] = None,
515
+ afnor_credentials: Optional[AFNORCredentials] = None,
516
+ polling_interval: Optional[int] = None,
517
+ polling_timeout: Optional[int] = None,
518
+ max_retries: Optional[int] = None,
519
+ ):
520
+ self.email = email
521
+ self.password = password
522
+ self.api_url = (api_url or self.DEFAULT_API_URL).rstrip("/")
523
+ self.client_uid = client_uid
524
+ self.chorus_credentials = chorus_credentials
525
+ self.afnor_credentials = afnor_credentials
526
+ self.polling_interval = polling_interval or self.DEFAULT_POLLING_INTERVAL
527
+ self.polling_timeout = polling_timeout or self.DEFAULT_POLLING_TIMEOUT
528
+ self.max_retries = max_retries if max_retries is not None else self.DEFAULT_MAX_RETRIES
529
+
530
+ self._access_token: Optional[str] = None
531
+ self._refresh_token: Optional[str] = None
532
+ self._token_expires_at: Optional[datetime] = None
533
+ self._api_client: Optional[ApiClient] = None
534
+
535
+ def get_chorus_credentials_for_api(self) -> Optional[Dict[str, Any]]:
536
+ """Retourne les credentials Chorus Pro au format API."""
537
+ return self.chorus_credentials.to_dict() if self.chorus_credentials else None
538
+
539
+ def get_afnor_credentials_for_api(self) -> Optional[Dict[str, Any]]:
540
+ """Retourne les credentials AFNOR au format API."""
541
+ return self.afnor_credentials.to_dict() if self.afnor_credentials else None
542
+
543
+ # Alias plus courts pour faciliter l'usage
544
+ def get_chorus_pro_credentials(self) -> Optional[Dict[str, Any]]:
545
+ """Alias pour get_chorus_credentials_for_api()."""
546
+ return self.get_chorus_credentials_for_api()
547
+
548
+ def get_afnor_credentials(self) -> Optional[Dict[str, Any]]:
549
+ """Alias pour get_afnor_credentials_for_api()."""
550
+ return self.get_afnor_credentials_for_api()
551
+
552
+ def _obtain_token(self) -> Dict[str, str]:
553
+ """Obtient un nouveau token JWT."""
554
+ token_url = f"{self.api_url}/api/token/"
555
+ payload = {"username": self.email, "password": self.password}
556
+ if self.client_uid:
557
+ payload["client_uid"] = self.client_uid
558
+
559
+ try:
560
+ response = requests.post(token_url, json=payload, timeout=30)
561
+ response.raise_for_status()
562
+ logger.info("Token JWT obtenu pour %s", self.email)
563
+ return response.json()
564
+ except requests.RequestException as e:
565
+ error_detail = ""
566
+ if hasattr(e, "response") and e.response is not None:
567
+ try:
568
+ error_detail = e.response.json().get("detail", str(e))
569
+ except Exception:
570
+ error_detail = str(e)
571
+ raise FactPulseAuthError(f"Impossible d'obtenir le token JWT: {error_detail or e}")
572
+
573
+ def _refresh_access_token(self) -> str:
574
+ """Rafraîchit le token d'accès."""
575
+ if not self._refresh_token:
576
+ raise FactPulseAuthError("Aucun refresh token disponible")
577
+
578
+ refresh_url = f"{self.api_url}/api/token/refresh/"
579
+ try:
580
+ response = requests.post(
581
+ refresh_url, json={"refresh": self._refresh_token}, timeout=30
582
+ )
583
+ response.raise_for_status()
584
+ logger.info("Token rafraîchi avec succès")
585
+ return response.json()["access"]
586
+ except requests.RequestException:
587
+ logger.warning("Refresh échoué, ré-obtention d'un nouveau token")
588
+ tokens = self._obtain_token()
589
+ self._refresh_token = tokens["refresh"]
590
+ return tokens["access"]
591
+
592
+ def ensure_authenticated(self, force_refresh: bool = False) -> None:
593
+ """S'assure que le client est authentifié."""
594
+ now = datetime.now()
595
+
596
+ if force_refresh or not self._access_token or not self._token_expires_at or now >= self._token_expires_at:
597
+ if self._refresh_token and self._token_expires_at and not force_refresh:
598
+ try:
599
+ self._access_token = self._refresh_access_token()
600
+ self._token_expires_at = now + timedelta(minutes=28)
601
+ return
602
+ except FactPulseAuthError:
603
+ pass
604
+
605
+ tokens = self._obtain_token()
606
+ self._access_token = tokens["access"]
607
+ self._refresh_token = tokens["refresh"]
608
+ self._token_expires_at = now + timedelta(minutes=28)
609
+
610
+ def reset_auth(self) -> None:
611
+ """Réinitialise l'authentification."""
612
+ self._access_token = None
613
+ self._refresh_token = None
614
+ self._token_expires_at = None
615
+ self._api_client = None
616
+ logger.info("Authentification réinitialisée")
617
+
618
+ def get_traitement_api(self) -> TraitementFactureApi:
619
+ """Retourne l'API de traitement de factures."""
620
+ self.ensure_authenticated()
621
+ config = Configuration(host=f"{self.api_url}/api/facturation")
622
+ config.access_token = self._access_token
623
+ self._api_client = ApiClient(configuration=config)
624
+ return TraitementFactureApi(api_client=self._api_client)
625
+
626
+ def poll_task(self, task_id: str, timeout: Optional[int] = None, interval: Optional[int] = None) -> Dict[str, Any]:
627
+ """Effectue un polling sur une tâche jusqu'à son achèvement."""
628
+ timeout_ms = timeout or self.polling_timeout
629
+ interval_ms = interval or self.polling_interval
630
+
631
+ start_time = time.time() * 1000
632
+ current_interval = float(interval_ms)
633
+
634
+ logger.info("Début du polling pour la tâche %s (timeout: %dms)", task_id, timeout_ms)
635
+
636
+ while True:
637
+ elapsed = (time.time() * 1000) - start_time
638
+
639
+ if elapsed > timeout_ms:
640
+ raise FactPulsePollingTimeout(task_id, timeout_ms)
641
+
642
+ try:
643
+ logger.debug("Polling tâche %s (elapsed: %.0fms)...", task_id, elapsed)
644
+ api = self.get_traitement_api()
645
+ statut = api.obtenir_statut_tache_api_v1_traitement_taches_id_tache_statut_get(id_tache=task_id)
646
+ logger.debug("Réponse statut reçue: %s", statut)
647
+
648
+ status_value = statut.statut.value if hasattr(statut.statut, "value") else str(statut.statut)
649
+ logger.info("Tâche %s: statut=%s (%.0fms)", task_id, status_value, elapsed)
650
+
651
+ if status_value == "SUCCESS":
652
+ logger.info("Tâche %s terminée avec succès", task_id)
653
+ if statut.resultat:
654
+ if hasattr(statut.resultat, "to_dict"):
655
+ return statut.resultat.to_dict()
656
+ return dict(statut.resultat)
657
+ return {}
658
+
659
+ if status_value == "FAILURE":
660
+ error_msg = "Erreur inconnue"
661
+ errors = []
662
+ if statut.resultat:
663
+ result = statut.resultat.to_dict() if hasattr(statut.resultat, "to_dict") else dict(statut.resultat)
664
+ # Format AFNOR: errorMessage, details
665
+ error_msg = result.get("errorMessage", error_msg)
666
+ for err in result.get("details", []):
667
+ errors.append(ValidationErrorDetail(
668
+ level=err.get("level", ""),
669
+ item=err.get("item", ""),
670
+ reason=err.get("reason", ""),
671
+ source=err.get("source"),
672
+ code=err.get("code"),
673
+ ))
674
+ raise FactPulseValidationError(f"La tâche {task_id} a échoué: {error_msg}", errors)
675
+
676
+ except (FactPulseValidationError, FactPulsePollingTimeout):
677
+ raise
678
+ except Exception as e:
679
+ error_str = str(e)
680
+ logger.warning("Erreur lors du polling: %s", error_str)
681
+
682
+ # Rate limit (429) - attendre et réessayer avec backoff
683
+ if "429" in error_str:
684
+ wait_time = min(current_interval * 2, 30000) # Max 30s
685
+ logger.warning("Rate limit (429), attente de %.1fs avant retry...", wait_time / 1000)
686
+ time.sleep(wait_time / 1000)
687
+ current_interval = wait_time
688
+ continue
689
+
690
+ # Token expiré (401) - re-authentification
691
+ if "401" in error_str:
692
+ logger.warning("Token expiré, re-authentification...")
693
+ self.reset_auth()
694
+ continue
695
+
696
+ # Erreur serveur temporaire (502, 503, 504) - retry avec backoff
697
+ if any(code in error_str for code in ("502", "503", "504")):
698
+ wait_time = min(current_interval * 1.5, 15000)
699
+ logger.warning("Erreur serveur temporaire, attente de %.1fs avant retry...", wait_time / 1000)
700
+ time.sleep(wait_time / 1000)
701
+ current_interval = wait_time
702
+ continue
703
+
704
+ raise FactPulseValidationError(f"Erreur API: {e}")
705
+
706
+ time.sleep(current_interval / 1000)
707
+ current_interval = min(current_interval * 1.5, 10000)
708
+
709
+ def generer_facturx(
710
+ self,
711
+ facture_data: Union[Dict, str, Any],
712
+ pdf_source: Union[bytes, str, Path],
713
+ profil: str = "EN16931",
714
+ format_sortie: str = "pdf",
715
+ sync: bool = True,
716
+ timeout: Optional[int] = None,
717
+ ) -> bytes:
718
+ """Génère une facture Factur-X.
719
+
720
+ Accepte les données de facture sous plusieurs formes :
721
+ - Dict : dictionnaire Python (recommandé avec les helpers montant_total(), ligne_de_poste(), etc.)
722
+ - str : JSON sérialisé
723
+ - Modèle Pydantic : modèle généré par le SDK (sera converti via .to_dict())
724
+
725
+ Args:
726
+ facture_data: Données de la facture (dict, JSON string, ou modèle Pydantic)
727
+ pdf_source: Chemin vers le PDF source, ou bytes du PDF
728
+ profil: Profil Factur-X (MINIMUM, BASIC, EN16931, EXTENDED)
729
+ format_sortie: Format de sortie (pdf, xml, both)
730
+ sync: Si True, attend la fin de la tâche et retourne le résultat
731
+ timeout: Timeout en ms pour le polling
732
+
733
+ Returns:
734
+ bytes: Contenu du fichier généré (PDF ou XML)
735
+ """
736
+ # Conversion des données en JSON string (gère Decimal, datetime, etc.)
737
+ if isinstance(facture_data, str):
738
+ json_data = facture_data
739
+ elif isinstance(facture_data, dict):
740
+ json_data = json_dumps_safe(facture_data)
741
+ elif hasattr(facture_data, "to_dict"):
742
+ # Modèle Pydantic généré par le SDK
743
+ json_data = json_dumps_safe(facture_data.to_dict())
744
+ else:
745
+ raise FactPulseValidationError(f"Type de données non supporté: {type(facture_data)}")
746
+
747
+ # Préparation du PDF
748
+ if isinstance(pdf_source, (str, Path)):
749
+ pdf_path = Path(pdf_source)
750
+ pdf_bytes = pdf_path.read_bytes()
751
+ pdf_filename = pdf_path.name
752
+ else:
753
+ pdf_bytes = pdf_source
754
+ pdf_filename = "source.pdf"
755
+
756
+ # Envoi direct via requests (bypass des modèles Pydantic du SDK)
757
+ for attempt in range(self.max_retries + 1):
758
+ self.ensure_authenticated()
759
+ try:
760
+ url = f"{self.api_url}/api/v1/traitement/generer-facture"
761
+ files = {
762
+ "donnees_facture": (None, json_data, "application/json"),
763
+ "profil": (None, profil),
764
+ "format_sortie": (None, format_sortie),
765
+ "source_pdf": (pdf_filename, pdf_bytes, "application/pdf"),
766
+ }
767
+ headers = {"Authorization": f"Bearer {self._access_token}"}
768
+ response = requests.post(url, files=files, headers=headers, timeout=60)
769
+
770
+ if response.status_code == 401 and attempt < self.max_retries:
771
+ logger.warning("Erreur 401, réinitialisation du token (tentative %d/%d)", attempt + 1, self.max_retries + 1)
772
+ self.reset_auth()
773
+ continue
774
+
775
+ response.raise_for_status()
776
+ result = response.json()
777
+ task_id = result.get("id_tache")
778
+
779
+ if not task_id:
780
+ raise FactPulseValidationError("Pas d'ID de tâche dans la réponse")
781
+
782
+ if not sync:
783
+ return task_id.encode()
784
+
785
+ poll_result = self.poll_task(task_id, timeout)
786
+
787
+ if poll_result.get("statut") == "ERREUR":
788
+ # Format AFNOR: errorMessage, details
789
+ error_msg = poll_result.get("errorMessage", "Erreur de validation")
790
+ errors = [
791
+ ValidationErrorDetail(
792
+ level=e.get("level", ""),
793
+ item=e.get("item", ""),
794
+ reason=e.get("reason", ""),
795
+ source=e.get("source"),
796
+ code=e.get("code"),
797
+ )
798
+ for e in poll_result.get("details", [])
799
+ ]
800
+ raise FactPulseValidationError(error_msg, errors)
801
+
802
+ if "contenu_b64" in poll_result:
803
+ return base64.b64decode(poll_result["contenu_b64"])
804
+
805
+ raise FactPulseValidationError("Le résultat ne contient pas de contenu")
806
+
807
+ except requests.RequestException as e:
808
+ if attempt < self.max_retries:
809
+ logger.warning("Erreur réseau (tentative %d/%d): %s", attempt + 1, self.max_retries + 1, e)
810
+ continue
811
+ raise FactPulseValidationError(f"Erreur API: {e}")
812
+
813
+ raise FactPulseValidationError("Échec après toutes les tentatives")
814
+
815
+ @staticmethod
816
+ def format_montant(montant) -> str:
817
+ """Formate un montant pour l'API FactPulse."""
818
+ if montant is None:
819
+ return "0.00"
820
+ if isinstance(montant, Decimal):
821
+ return f"{montant:.2f}"
822
+ if isinstance(montant, (int, float)):
823
+ return f"{montant:.2f}"
824
+ if isinstance(montant, str):
825
+ return montant
826
+ return "0.00"
827
+
828
+ # =========================================================================
829
+ # AFNOR PDP/PA - Flow Service
830
+ # =========================================================================
831
+ #
832
+ # ARCHITECTURE GRAVÉE DANS LE MARBRE - NE PAS MODIFIER SANS COMPRENDRE
833
+ #
834
+ # Le proxy AFNOR est 100% TRANSPARENT. Il a le même OpenAPI que l'AFNOR.
835
+ # Le SDK doit TOUJOURS :
836
+ # 1. Obtenir les credentials AFNOR (mode stored: via /credentials, mode zero-trust: fournis)
837
+ # 2. Faire l'OAuth AFNOR lui-même
838
+ # 3. Appeler les endpoints avec le token AFNOR + header X-PDP-Base-URL
839
+ #
840
+ # Le token JWT FactPulse n'est JAMAIS utilisé pour appeler la PDP !
841
+ # =========================================================================
842
+
843
+ def _get_afnor_credentials(self) -> "AFNORCredentials":
844
+ """Obtient les credentials AFNOR (mode stored ou zero-trust).
845
+
846
+ **Mode zero-trust** : Retourne les afnor_credentials fournis au constructeur.
847
+ **Mode stored** : Récupère les credentials via GET /api/v1/afnor/credentials.
848
+
849
+ Returns:
850
+ AFNORCredentials avec flow_service_url, token_url, client_id, client_secret
851
+
852
+ Raises:
853
+ FactPulseAuthError: Si pas de credentials disponibles
854
+ FactPulseServiceUnavailableError: Si le serveur est indisponible
855
+ """
856
+ from .exceptions import FactPulseServiceUnavailableError
857
+
858
+ # Mode zero-trust : credentials fournis au constructeur
859
+ if self.afnor_credentials:
860
+ logger.info("Mode zero-trust: utilisation des AFNORCredentials fournis")
861
+ return self.afnor_credentials
862
+
863
+ # Mode stored : récupérer les credentials via l'API
864
+ logger.info("Mode stored: récupération des credentials via /api/v1/afnor/credentials")
865
+
866
+ self.ensure_authenticated() # S'assurer qu'on a un token JWT FactPulse
867
+
868
+ url = f"{self.api_url}/api/v1/afnor/credentials"
869
+ headers = {"Authorization": f"Bearer {self._access_token}"}
870
+
871
+ try:
872
+ response = requests.get(url, headers=headers, timeout=10)
873
+ except requests.RequestException as e:
874
+ raise FactPulseServiceUnavailableError("FactPulse AFNOR credentials", e)
875
+
876
+ if response.status_code == 400:
877
+ error_json = response.json()
878
+ error_detail = error_json.get("detail", {})
879
+ if isinstance(error_detail, dict) and error_detail.get("error") == "NO_CLIENT_UID":
880
+ raise FactPulseAuthError(
881
+ "Aucun client_uid dans le JWT. "
882
+ "Pour utiliser les endpoints AFNOR, soit :\n"
883
+ "1. Générez un token avec un client_uid (mode stored)\n"
884
+ "2. Fournissez AFNORCredentials au constructeur du client (mode zero-trust)"
885
+ )
886
+ raise FactPulseAuthError(f"Erreur credentials AFNOR: {error_detail}")
887
+
888
+ if response.status_code != 200:
889
+ try:
890
+ error_json = response.json()
891
+ error_msg = error_json.get("detail", str(error_json))
892
+ except Exception:
893
+ error_msg = response.text or f"HTTP {response.status_code}"
894
+ raise FactPulseAuthError(f"Échec récupération credentials AFNOR: {error_msg}")
895
+
896
+ creds = response.json()
897
+ logger.info(f"Credentials AFNOR récupérés pour PDP: {creds.get('flow_service_url')}")
898
+
899
+ # Créer un AFNORCredentials temporaire
900
+ return AFNORCredentials(
901
+ flow_service_url=creds["flow_service_url"],
902
+ token_url=creds["token_url"],
903
+ client_id=creds["client_id"],
904
+ client_secret=creds["client_secret"],
905
+ )
906
+
907
+ def _get_afnor_token_and_url(self) -> Tuple[str, str]:
908
+ """Obtient le token OAuth2 AFNOR et l'URL de la PDP.
909
+
910
+ Cette méthode :
911
+ 1. Récupère les credentials AFNOR (mode stored ou zero-trust)
912
+ 2. Fait l'OAuth AFNOR pour obtenir un token
913
+ 3. Retourne le token et l'URL de la PDP
914
+
915
+ Returns:
916
+ Tuple (afnor_token, pdp_base_url)
917
+
918
+ Raises:
919
+ FactPulseAuthError: Si l'authentification échoue
920
+ FactPulseServiceUnavailableError: Si le service est indisponible
921
+ """
922
+ from .exceptions import FactPulseServiceUnavailableError
923
+
924
+ # Étape 1: Obtenir les credentials AFNOR
925
+ credentials = self._get_afnor_credentials()
926
+
927
+ # Étape 2: Faire l'OAuth AFNOR
928
+ logger.info(f"OAuth AFNOR vers: {credentials.token_url}")
929
+
930
+ url = f"{self.api_url}/api/v1/afnor/oauth/token"
931
+ oauth_data = {
932
+ "grant_type": "client_credentials",
933
+ "client_id": credentials.client_id,
934
+ "client_secret": credentials.client_secret,
935
+ }
936
+ headers = {
937
+ "X-PDP-Token-URL": credentials.token_url,
938
+ }
939
+
940
+ try:
941
+ response = requests.post(url, data=oauth_data, headers=headers, timeout=10)
942
+ except requests.RequestException as e:
943
+ raise FactPulseServiceUnavailableError("AFNOR OAuth", e)
944
+
945
+ if response.status_code != 200:
946
+ try:
947
+ error_json = response.json()
948
+ error_msg = error_json.get("detail", error_json.get("error", str(error_json)))
949
+ except Exception:
950
+ error_msg = response.text or f"HTTP {response.status_code}"
951
+ raise FactPulseAuthError(f"Échec OAuth2 AFNOR: {error_msg}")
952
+
953
+ token_data = response.json()
954
+ afnor_token = token_data.get("access_token")
955
+
956
+ if not afnor_token:
957
+ raise FactPulseAuthError("Réponse OAuth2 AFNOR invalide: access_token manquant")
958
+
959
+ logger.info("Token OAuth2 AFNOR obtenu avec succès")
960
+ return afnor_token, credentials.flow_service_url
961
+
962
+ def _make_afnor_request(
963
+ self,
964
+ method: str,
965
+ endpoint: str,
966
+ json_data: Optional[Dict] = None,
967
+ files: Optional[Dict] = None,
968
+ params: Optional[Dict] = None,
969
+ ) -> requests.Response:
970
+ """Effectue une requête vers l'API AFNOR avec gestion d'auth et d'erreurs.
971
+
972
+ ================================================================================
973
+ ARCHITECTURE GRAVÉE DANS LE MARBRE
974
+ ================================================================================
975
+
976
+ Cette méthode :
977
+ 1. Récupère les credentials AFNOR (mode stored: API, mode zero-trust: fournis)
978
+ 2. Fait l'OAuth AFNOR pour obtenir un token AFNOR
979
+ 3. Appelle l'endpoint avec :
980
+ - Authorization: Bearer {token_afnor} ← TOKEN AFNOR, PAS JWT FACTPULSE !
981
+ - X-PDP-Base-URL: {url_pdp} ← Pour que le proxy route vers la bonne PDP
982
+
983
+ Le token JWT FactPulse n'est JAMAIS utilisé pour appeler la PDP.
984
+ Il sert uniquement à récupérer les credentials en mode stored.
985
+
986
+ ================================================================================
987
+
988
+ Args:
989
+ method: Méthode HTTP (GET, POST, etc.)
990
+ endpoint: Endpoint relatif (ex: /flow/v1/flows)
991
+ json_data: Données JSON (optionnel)
992
+ files: Fichiers multipart (optionnel)
993
+ params: Query params (optionnel)
994
+
995
+ Returns:
996
+ Response de l'API
997
+
998
+ Raises:
999
+ FactPulseAuthError: Si 401 ou credentials manquants
1000
+ FactPulseNotFoundError: Si 404
1001
+ FactPulseServiceUnavailableError: Si 503
1002
+ FactPulseValidationError: Si 400/422
1003
+ FactPulseAPIError: Autres erreurs
1004
+ """
1005
+ from .exceptions import (
1006
+ parse_api_error,
1007
+ FactPulseServiceUnavailableError,
1008
+ )
1009
+
1010
+ # Obtenir le token AFNOR et l'URL de la PDP
1011
+ # (mode stored: récupère credentials via API, mode zero-trust: utilise credentials fournis)
1012
+ afnor_token, pdp_base_url = self._get_afnor_token_and_url()
1013
+
1014
+ url = f"{self.api_url}/api/v1/afnor{endpoint}"
1015
+
1016
+ # TOUJOURS utiliser le token AFNOR + header X-PDP-Base-URL
1017
+ # Le token JWT FactPulse n'est JAMAIS utilisé pour appeler la PDP !
1018
+ headers = {
1019
+ "Authorization": f"Bearer {afnor_token}",
1020
+ "X-PDP-Base-URL": pdp_base_url,
1021
+ }
1022
+
1023
+ try:
1024
+ if files:
1025
+ response = requests.request(
1026
+ method, url, files=files, headers=headers, params=params, timeout=60
1027
+ )
1028
+ else:
1029
+ response = requests.request(
1030
+ method, url, json=json_data, headers=headers, params=params, timeout=30
1031
+ )
1032
+ except requests.RequestException as e:
1033
+ raise FactPulseServiceUnavailableError("AFNOR PDP", e)
1034
+
1035
+ if response.status_code >= 400:
1036
+ try:
1037
+ error_json = response.json()
1038
+ except Exception:
1039
+ error_json = {"errorMessage": response.text or f"Erreur HTTP {response.status_code}"}
1040
+ raise parse_api_error(error_json, response.status_code)
1041
+
1042
+ return response
1043
+
1044
+ def soumettre_facture_afnor(
1045
+ self,
1046
+ flow_name: str,
1047
+ pdf_path: Optional[Union[str, Path]] = None,
1048
+ pdf_bytes: Optional[bytes] = None,
1049
+ pdf_filename: str = "facture.pdf",
1050
+ flow_syntax: str = "CII",
1051
+ flow_profile: str = "EN16931",
1052
+ tracking_id: Optional[str] = None,
1053
+ sha256: Optional[str] = None,
1054
+ ) -> Dict[str, Any]:
1055
+ """Soumet une facture Factur-X à une PDP via l'API AFNOR.
1056
+
1057
+ L'authentification utilise soit le client_uid du JWT (mode stocké),
1058
+ soit les afnor_credentials fournis au constructeur (mode zero-trust).
1059
+
1060
+ Args:
1061
+ flow_name: Nom du flux (ex: "Facture FAC-2025-001")
1062
+ pdf_path: Chemin vers le fichier PDF/A-3 (exclusif avec pdf_bytes)
1063
+ pdf_bytes: Contenu PDF en bytes (exclusif avec pdf_path)
1064
+ pdf_filename: Nom du fichier pour les bytes (défaut: "facture.pdf")
1065
+ flow_syntax: Syntaxe du flux (CII ou UBL)
1066
+ flow_profile: Profil Factur-X (MINIMUM, BASIC, EN16931, EXTENDED)
1067
+ tracking_id: Identifiant de suivi métier (optionnel)
1068
+ sha256: Empreinte SHA-256 du fichier (calculée auto si absent)
1069
+
1070
+ Returns:
1071
+ Dict avec flowId, trackingId, status, sha256, etc.
1072
+
1073
+ Raises:
1074
+ FactPulseValidationError: Si le PDF n'est pas valide
1075
+ FactPulseServiceUnavailableError: Si la PDP est indisponible
1076
+ ValueError: Si ni pdf_path ni pdf_bytes n'est fourni
1077
+
1078
+ Example:
1079
+ >>> # Avec un chemin de fichier
1080
+ >>> result = client.soumettre_facture_afnor(
1081
+ ... flow_name="Facture FAC-2025-001",
1082
+ ... pdf_path="facture.pdf",
1083
+ ... tracking_id="FAC-2025-001",
1084
+ ... )
1085
+ >>> print(result["flowId"])
1086
+
1087
+ >>> # Avec des bytes (ex: après génération Factur-X)
1088
+ >>> result = client.soumettre_facture_afnor(
1089
+ ... flow_name="Facture FAC-2025-001",
1090
+ ... pdf_bytes=pdf_content,
1091
+ ... pdf_filename="FAC-2025-001.pdf",
1092
+ ... tracking_id="FAC-2025-001",
1093
+ ... )
1094
+ """
1095
+ import hashlib
1096
+
1097
+ # Charger le PDF depuis le chemin si fourni
1098
+ filename = pdf_filename
1099
+ if pdf_path:
1100
+ pdf_path = Path(pdf_path)
1101
+ pdf_bytes = pdf_path.read_bytes()
1102
+ filename = pdf_path.name
1103
+
1104
+ if not pdf_bytes:
1105
+ raise ValueError("pdf_path ou pdf_bytes requis")
1106
+
1107
+ # Calculer SHA-256 si non fourni
1108
+ if not sha256:
1109
+ sha256 = hashlib.sha256(pdf_bytes).hexdigest()
1110
+
1111
+ # Préparer flowInfo
1112
+ flow_info = {
1113
+ "name": flow_name,
1114
+ "flowSyntax": flow_syntax,
1115
+ "flowProfile": flow_profile,
1116
+ "sha256": sha256,
1117
+ }
1118
+ if tracking_id:
1119
+ flow_info["trackingId"] = tracking_id
1120
+
1121
+ files = {
1122
+ "file": (filename, pdf_bytes, "application/pdf"),
1123
+ "flowInfo": (None, json_dumps_safe(flow_info), "application/json"),
1124
+ }
1125
+
1126
+ response = self._make_afnor_request("POST", "/flow/v1/flows", files=files)
1127
+ return response.json()
1128
+
1129
+ def rechercher_flux_afnor(
1130
+ self,
1131
+ tracking_id: Optional[str] = None,
1132
+ status: Optional[str] = None,
1133
+ offset: int = 0,
1134
+ limit: int = 25,
1135
+ ) -> Dict[str, Any]:
1136
+ """Recherche des flux de facturation AFNOR.
1137
+
1138
+ Args:
1139
+ tracking_id: Filtrer par trackingId
1140
+ status: Filtrer par status (submitted, processing, delivered, etc.)
1141
+ offset: Index de début (pagination)
1142
+ limit: Nombre max de résultats
1143
+
1144
+ Returns:
1145
+ Dict avec flows (liste), total, offset, limit
1146
+
1147
+ Example:
1148
+ >>> results = client.rechercher_flux_afnor(tracking_id="FAC-2025-001")
1149
+ >>> for flux in results["flows"]:
1150
+ ... print(flux["flowId"], flux["status"])
1151
+ """
1152
+ search_body = {
1153
+ "offset": offset,
1154
+ "limit": limit,
1155
+ "where": {},
1156
+ }
1157
+ if tracking_id:
1158
+ search_body["where"]["trackingId"] = tracking_id
1159
+ if status:
1160
+ search_body["where"]["status"] = status
1161
+
1162
+ response = self._make_afnor_request("POST", "/flow/v1/flows/search", json_data=search_body)
1163
+ return response.json()
1164
+
1165
+ def telecharger_flux_afnor(self, flow_id: str) -> bytes:
1166
+ """Télécharge le fichier PDF d'un flux AFNOR.
1167
+
1168
+ Args:
1169
+ flow_id: Identifiant du flux (UUID)
1170
+
1171
+ Returns:
1172
+ Contenu du fichier PDF
1173
+
1174
+ Raises:
1175
+ FactPulseNotFoundError: Si le flux n'existe pas
1176
+
1177
+ Example:
1178
+ >>> pdf_bytes = client.telecharger_flux_afnor("550e8400-e29b-41d4-a716-446655440000")
1179
+ >>> with open("facture.pdf", "wb") as f:
1180
+ ... f.write(pdf_bytes)
1181
+ """
1182
+ response = self._make_afnor_request("GET", f"/flow/v1/flows/{flow_id}")
1183
+ return response.content
1184
+
1185
+ def obtenir_facture_entrante_afnor(
1186
+ self,
1187
+ flow_id: str,
1188
+ include_document: bool = False,
1189
+ ) -> Dict[str, Any]:
1190
+ """Récupère les métadonnées JSON d'un flux entrant (facture fournisseur).
1191
+
1192
+ Télécharge un flux entrant depuis la PDP AFNOR et extrait les métadonnées
1193
+ de la facture vers un format JSON unifié. Supporte les formats Factur-X, CII et UBL.
1194
+
1195
+ Note: Cet endpoint utilise l'authentification JWT FactPulse (pas OAuth AFNOR).
1196
+ Le serveur FactPulse se charge d'appeler la PDP avec les credentials stockés.
1197
+
1198
+ Args:
1199
+ flow_id: Identifiant du flux (UUID)
1200
+ include_document: Si True, inclut le document original encodé en base64
1201
+
1202
+ Returns:
1203
+ Dict avec les métadonnées de la facture:
1204
+ - flow_id: Identifiant du flux
1205
+ - format_source: Format détecté (Factur-X, CII, UBL)
1206
+ - ref_fournisseur: Numéro de facture fournisseur
1207
+ - type_document: Code type (380=facture, 381=avoir, etc.)
1208
+ - fournisseur: Dict avec nom, siret, numero_tva_intra
1209
+ - site_facturation_nom: Nom du destinataire
1210
+ - site_facturation_siret: SIRET du destinataire
1211
+ - date_de_piece: Date de la facture (YYYY-MM-DD)
1212
+ - date_reglement: Date d'échéance (YYYY-MM-DD)
1213
+ - devise: Code devise (EUR, USD, etc.)
1214
+ - montant_ht: Montant HT
1215
+ - montant_tva: Montant TVA
1216
+ - montant_ttc: Montant TTC
1217
+ - document_base64: (si include_document=True) Document encodé
1218
+ - document_content_type: (si include_document=True) Type MIME
1219
+ - document_filename: (si include_document=True) Nom de fichier
1220
+
1221
+ Raises:
1222
+ FactPulseNotFoundError: Si le flux n'existe pas
1223
+ FactPulseValidationError: Si le format n'est pas supporté
1224
+
1225
+ Example:
1226
+ >>> # Récupérer les métadonnées d'une facture entrante
1227
+ >>> facture = client.obtenir_facture_entrante_afnor("550e8400-e29b-41d4-a716-446655440000")
1228
+ >>> print(f"Fournisseur: {facture['fournisseur']['nom']}")
1229
+ >>> print(f"Montant TTC: {facture['montant_ttc']} {facture['devise']}")
1230
+
1231
+ >>> # Avec le document original
1232
+ >>> facture = client.obtenir_facture_entrante_afnor(flow_id, include_document=True)
1233
+ >>> if facture.get('document_base64'):
1234
+ ... import base64
1235
+ ... pdf_bytes = base64.b64decode(facture['document_base64'])
1236
+ ... with open(facture['document_filename'], 'wb') as f:
1237
+ ... f.write(pdf_bytes)
1238
+ """
1239
+ from .exceptions import FactPulseNotFoundError, FactPulseServiceUnavailableError, parse_api_error
1240
+
1241
+ self.ensure_authenticated()
1242
+
1243
+ url = f"{self.api_url}/api/v1/afnor/flux-entrants/{flow_id}"
1244
+ params = {}
1245
+ if include_document:
1246
+ params["include_document"] = "true"
1247
+
1248
+ headers = {"Authorization": f"Bearer {self._access_token}"}
1249
+
1250
+ try:
1251
+ response = requests.get(url, headers=headers, params=params if params else None, timeout=60)
1252
+ except requests.RequestException as e:
1253
+ raise FactPulseServiceUnavailableError("FactPulse AFNOR flux-entrants", e)
1254
+
1255
+ if response.status_code >= 400:
1256
+ try:
1257
+ error_json = response.json()
1258
+ except Exception:
1259
+ error_json = {"detail": response.text or f"Erreur HTTP {response.status_code}"}
1260
+ raise parse_api_error(error_json, response.status_code)
1261
+
1262
+ return response.json()
1263
+
1264
+ def healthcheck_afnor(self) -> Dict[str, Any]:
1265
+ """Vérifie la disponibilité du Flow Service AFNOR.
1266
+
1267
+ Returns:
1268
+ Dict avec status et service
1269
+
1270
+ Example:
1271
+ >>> status = client.healthcheck_afnor()
1272
+ >>> print(status["status"]) # "ok"
1273
+ """
1274
+ response = self._make_afnor_request("GET", "/flow/v1/healthcheck")
1275
+ return response.json()
1276
+
1277
+ # =========================================================================
1278
+ # Chorus Pro
1279
+ # =========================================================================
1280
+
1281
+ def _make_chorus_request(
1282
+ self,
1283
+ method: str,
1284
+ endpoint: str,
1285
+ json_data: Optional[Dict] = None,
1286
+ params: Optional[Dict] = None,
1287
+ ) -> requests.Response:
1288
+ """Effectue une requête vers l'API Chorus Pro avec gestion d'auth et d'erreurs.
1289
+
1290
+ Args:
1291
+ method: Méthode HTTP (GET, POST, etc.)
1292
+ endpoint: Endpoint relatif (ex: /structures/rechercher)
1293
+ json_data: Données JSON (optionnel)
1294
+ params: Query params (optionnel)
1295
+
1296
+ Returns:
1297
+ Response de l'API
1298
+ """
1299
+ from .exceptions import (
1300
+ parse_api_error,
1301
+ FactPulseServiceUnavailableError,
1302
+ )
1303
+
1304
+ self.ensure_authenticated()
1305
+ url = f"{self.api_url}/api/v1/chorus-pro{endpoint}"
1306
+
1307
+ headers = {"Authorization": f"Bearer {self._access_token}"}
1308
+
1309
+ # Ajouter credentials dans le body si mode zero-trust
1310
+ if json_data is None:
1311
+ json_data = {}
1312
+ if self.chorus_credentials:
1313
+ json_data["credentials"] = self.chorus_credentials.to_dict()
1314
+
1315
+ try:
1316
+ response = requests.request(
1317
+ method, url, json=json_data, headers=headers, params=params, timeout=30
1318
+ )
1319
+ except requests.RequestException as e:
1320
+ raise FactPulseServiceUnavailableError("Chorus Pro", e)
1321
+
1322
+ if response.status_code >= 400:
1323
+ try:
1324
+ error_json = response.json()
1325
+ except Exception:
1326
+ error_json = {"errorMessage": response.text or f"Erreur HTTP {response.status_code}"}
1327
+ raise parse_api_error(error_json, response.status_code)
1328
+
1329
+ return response
1330
+
1331
+ def rechercher_structure_chorus(
1332
+ self,
1333
+ identifiant_structure: Optional[str] = None,
1334
+ raison_sociale: Optional[str] = None,
1335
+ type_identifiant: str = "SIRET",
1336
+ restreindre_privees: bool = True,
1337
+ ) -> Dict[str, Any]:
1338
+ """Recherche des structures sur Chorus Pro.
1339
+
1340
+ Args:
1341
+ identifiant_structure: SIRET ou SIREN de la structure
1342
+ raison_sociale: Raison sociale (recherche partielle)
1343
+ type_identifiant: Type d'identifiant (SIRET, SIREN, etc.)
1344
+ restreindre_privees: Si True, limite aux structures privées
1345
+
1346
+ Returns:
1347
+ Dict avec liste_structures, total, code_retour, libelle
1348
+
1349
+ Example:
1350
+ >>> result = client.rechercher_structure_chorus(identifiant_structure="12345678901234")
1351
+ >>> for struct in result["liste_structures"]:
1352
+ ... print(struct["id_structure_cpp"], struct["designation_structure"])
1353
+ """
1354
+ body = {
1355
+ "restreindre_structures_privees": restreindre_privees,
1356
+ }
1357
+ if identifiant_structure:
1358
+ body["identifiant_structure"] = identifiant_structure
1359
+ if raison_sociale:
1360
+ body["raison_sociale_structure"] = raison_sociale
1361
+ if type_identifiant:
1362
+ body["type_identifiant_structure"] = type_identifiant
1363
+
1364
+ response = self._make_chorus_request("POST", "/structures/rechercher", json_data=body)
1365
+ return response.json()
1366
+
1367
+ def consulter_structure_chorus(self, id_structure_cpp: int) -> Dict[str, Any]:
1368
+ """Consulte les détails d'une structure Chorus Pro.
1369
+
1370
+ Retourne notamment les paramètres obligatoires pour soumettre une facture :
1371
+ - code_service_doit_etre_renseigne
1372
+ - numero_ej_doit_etre_renseigne
1373
+
1374
+ Args:
1375
+ id_structure_cpp: ID Chorus Pro de la structure
1376
+
1377
+ Returns:
1378
+ Dict avec les détails de la structure et ses paramètres
1379
+
1380
+ Example:
1381
+ >>> details = client.consulter_structure_chorus(12345)
1382
+ >>> if details["parametres"]["code_service_doit_etre_renseigne"]:
1383
+ ... print("Code service obligatoire")
1384
+ """
1385
+ body = {"id_structure_cpp": id_structure_cpp}
1386
+ response = self._make_chorus_request("POST", "/structures/consulter", json_data=body)
1387
+ return response.json()
1388
+
1389
+ def obtenir_id_chorus_depuis_siret(
1390
+ self,
1391
+ siret: str,
1392
+ type_identifiant: str = "SIRET",
1393
+ ) -> Dict[str, Any]:
1394
+ """Obtient l'ID Chorus Pro d'une structure depuis son SIRET.
1395
+
1396
+ Raccourci pratique pour obtenir l'id_structure_cpp avant de soumettre une facture.
1397
+
1398
+ Args:
1399
+ siret: Numéro SIRET ou SIREN
1400
+ type_identifiant: Type d'identifiant (SIRET ou SIREN)
1401
+
1402
+ Returns:
1403
+ Dict avec id_structure_cpp, designation_structure, message
1404
+
1405
+ Example:
1406
+ >>> result = client.obtenir_id_chorus_depuis_siret("12345678901234")
1407
+ >>> id_cpp = result["id_structure_cpp"]
1408
+ >>> if id_cpp > 0:
1409
+ ... print(f"Structure trouvée: {result['designation_structure']}")
1410
+ """
1411
+ body = {
1412
+ "siret": siret,
1413
+ "type_identifiant": type_identifiant,
1414
+ }
1415
+ response = self._make_chorus_request("POST", "/structures/obtenir-id-depuis-siret", json_data=body)
1416
+ return response.json()
1417
+
1418
+ def lister_services_structure_chorus(self, id_structure_cpp: int) -> Dict[str, Any]:
1419
+ """Liste les services d'une structure Chorus Pro.
1420
+
1421
+ Args:
1422
+ id_structure_cpp: ID Chorus Pro de la structure
1423
+
1424
+ Returns:
1425
+ Dict avec liste_services, total, code_retour, libelle
1426
+
1427
+ Example:
1428
+ >>> services = client.lister_services_structure_chorus(12345)
1429
+ >>> for svc in services["liste_services"]:
1430
+ ... if svc["est_actif"]:
1431
+ ... print(svc["code_service"], svc["libelle_service"])
1432
+ """
1433
+ response = self._make_chorus_request("GET", f"/structures/{id_structure_cpp}/services")
1434
+ return response.json()
1435
+
1436
+ def soumettre_facture_chorus(
1437
+ self,
1438
+ numero_facture: str,
1439
+ date_facture: str,
1440
+ date_echeance_paiement: str,
1441
+ id_structure_cpp: int,
1442
+ montant_ht_total: str,
1443
+ montant_tva: str,
1444
+ montant_ttc_total: str,
1445
+ piece_jointe_principale_id: Optional[int] = None,
1446
+ piece_jointe_principale_designation: str = "Facture",
1447
+ code_service: Optional[str] = None,
1448
+ numero_engagement: Optional[str] = None,
1449
+ numero_bon_commande: Optional[str] = None,
1450
+ numero_marche: Optional[str] = None,
1451
+ commentaire: Optional[str] = None,
1452
+ ) -> Dict[str, Any]:
1453
+ """Soumet une facture à Chorus Pro.
1454
+
1455
+ **Workflow complet** :
1456
+ 1. Obtenir l'id_structure_cpp via rechercher_structure_chorus()
1457
+ 2. Vérifier les paramètres obligatoires via consulter_structure_chorus()
1458
+ 3. Uploader le PDF via l'API /transverses/ajouter-fichier
1459
+ 4. Soumettre la facture avec cette méthode
1460
+
1461
+ Args:
1462
+ numero_facture: Numéro de la facture
1463
+ date_facture: Date de la facture (YYYY-MM-DD)
1464
+ date_echeance_paiement: Date d'échéance (YYYY-MM-DD)
1465
+ id_structure_cpp: ID Chorus Pro du destinataire
1466
+ montant_ht_total: Montant HT total (ex: "1000.00")
1467
+ montant_tva: Montant TVA (ex: "200.00")
1468
+ montant_ttc_total: Montant TTC total (ex: "1200.00")
1469
+ piece_jointe_principale_id: ID de la pièce jointe (optionnel)
1470
+ piece_jointe_principale_designation: Désignation (défaut: "Facture")
1471
+ code_service: Code service (si requis par la structure)
1472
+ numero_engagement: Numéro d'engagement (si requis)
1473
+ numero_bon_commande: Numéro de bon de commande
1474
+ numero_marche: Numéro de marché
1475
+ commentaire: Commentaire libre
1476
+
1477
+ Returns:
1478
+ Dict avec identifiant_facture_cpp, numero_flux_depot, code_retour, libelle
1479
+
1480
+ Example:
1481
+ >>> result = client.soumettre_facture_chorus(
1482
+ ... numero_facture="FAC-2025-001",
1483
+ ... date_facture="2025-01-15",
1484
+ ... date_echeance_paiement="2025-02-15",
1485
+ ... id_structure_cpp=12345,
1486
+ ... montant_ht_total="1000.00",
1487
+ ... montant_tva="200.00",
1488
+ ... montant_ttc_total="1200.00",
1489
+ ... )
1490
+ >>> print(f"Facture soumise: {result['identifiant_facture_cpp']}")
1491
+ """
1492
+ body = {
1493
+ "numero_facture": numero_facture,
1494
+ "date_facture": date_facture,
1495
+ "date_echeance_paiement": date_echeance_paiement,
1496
+ "id_structure_cpp": id_structure_cpp,
1497
+ "montant_ht_total": montant_ht_total,
1498
+ "montant_tva": montant_tva,
1499
+ "montant_ttc_total": montant_ttc_total,
1500
+ }
1501
+ if piece_jointe_principale_id:
1502
+ body["piece_jointe_principale_id"] = piece_jointe_principale_id
1503
+ body["piece_jointe_principale_designation"] = piece_jointe_principale_designation
1504
+ if code_service:
1505
+ body["code_service"] = code_service
1506
+ if numero_engagement:
1507
+ body["numero_engagement"] = numero_engagement
1508
+ if numero_bon_commande:
1509
+ body["numero_bon_commande"] = numero_bon_commande
1510
+ if numero_marche:
1511
+ body["numero_marche"] = numero_marche
1512
+ if commentaire:
1513
+ body["commentaire"] = commentaire
1514
+
1515
+ response = self._make_chorus_request("POST", "/factures/soumettre", json_data=body)
1516
+ return response.json()
1517
+
1518
+ def consulter_facture_chorus(self, identifiant_facture_cpp: int) -> Dict[str, Any]:
1519
+ """Consulte le statut d'une facture Chorus Pro.
1520
+
1521
+ Args:
1522
+ identifiant_facture_cpp: ID Chorus Pro de la facture
1523
+
1524
+ Returns:
1525
+ Dict avec statut_courant, numero_facture, date_facture, montant_ttc_total, etc.
1526
+
1527
+ Example:
1528
+ >>> status = client.consulter_facture_chorus(12345)
1529
+ >>> print(f"Statut: {status['statut_courant']['code']}")
1530
+ """
1531
+ body = {"identifiant_facture_cpp": identifiant_facture_cpp}
1532
+ response = self._make_chorus_request("POST", "/factures/consulter", json_data=body)
1533
+ return response.json()
1534
+
1535
+ # ==================== AFNOR Directory ====================
1536
+
1537
+ def rechercher_siret_afnor(self, siret: str) -> Dict[str, Any]:
1538
+ """Recherche une entreprise par SIRET dans l'annuaire AFNOR.
1539
+
1540
+ Args:
1541
+ siret: Numéro SIRET (14 chiffres)
1542
+
1543
+ Returns:
1544
+ Dict avec informations entreprise: raison_sociale, adresse, etc.
1545
+
1546
+ Example:
1547
+ >>> result = client.rechercher_siret_afnor("12345678901234")
1548
+ >>> print(f"Entreprise: {result['raison_sociale']}")
1549
+ """
1550
+ response = self._make_afnor_request("GET", f"/directory/siret/{siret}")
1551
+ return response.json()
1552
+
1553
+ def rechercher_siren_afnor(self, siren: str) -> Dict[str, Any]:
1554
+ """Recherche une entreprise par SIREN dans l'annuaire AFNOR.
1555
+
1556
+ Args:
1557
+ siren: Numéro SIREN (9 chiffres)
1558
+
1559
+ Returns:
1560
+ Dict avec informations entreprise et liste des établissements
1561
+
1562
+ Example:
1563
+ >>> result = client.rechercher_siren_afnor("123456789")
1564
+ >>> for etab in result.get('etablissements', []):
1565
+ ... print(f"SIRET: {etab['siret']}")
1566
+ """
1567
+ response = self._make_afnor_request("GET", f"/directory/siren/{siren}")
1568
+ return response.json()
1569
+
1570
+ def lister_codes_routage_afnor(self, siren: str) -> List[Dict[str, Any]]:
1571
+ """Liste les codes de routage disponibles pour un SIREN.
1572
+
1573
+ Args:
1574
+ siren: Numéro SIREN (9 chiffres)
1575
+
1576
+ Returns:
1577
+ Liste des codes de routage avec leurs paramètres
1578
+
1579
+ Example:
1580
+ >>> codes = client.lister_codes_routage_afnor("123456789")
1581
+ >>> for code in codes:
1582
+ ... print(f"Code: {code['code_routage']}")
1583
+ """
1584
+ response = self._make_afnor_request("GET", f"/directory/siren/{siren}/routing-codes")
1585
+ return response.json()
1586
+
1587
+ # ==================== Validation ====================
1588
+
1589
+ def valider_pdf_facturx(
1590
+ self,
1591
+ pdf_path: Optional[str] = None,
1592
+ pdf_bytes: Optional[bytes] = None,
1593
+ profil: str = "EN16931"
1594
+ ) -> Dict[str, Any]:
1595
+ """Valide un PDF Factur-X.
1596
+
1597
+ Args:
1598
+ pdf_path: Chemin vers le fichier PDF (exclusif avec pdf_bytes)
1599
+ pdf_bytes: Contenu PDF en bytes (exclusif avec pdf_path)
1600
+ profil: Profil Factur-X attendu (MINIMUM, BASIC, EN16931, EXTENDED)
1601
+
1602
+ Returns:
1603
+ Dict avec: est_conforme (bool), erreurs (list), avertissements (list), profil_detecte
1604
+
1605
+ Example:
1606
+ >>> result = client.valider_pdf_facturx("facture.pdf")
1607
+ >>> if result['est_conforme']:
1608
+ ... print("PDF Factur-X valide!")
1609
+ >>> else:
1610
+ ... for err in result['erreurs']:
1611
+ ... print(f"Erreur: {err}")
1612
+ """
1613
+ if pdf_path:
1614
+ with open(pdf_path, "rb") as f:
1615
+ pdf_bytes = f.read()
1616
+ if not pdf_bytes:
1617
+ raise ValueError("pdf_path ou pdf_bytes requis")
1618
+
1619
+ files = {"fichier_pdf": ("facture.pdf", pdf_bytes, "application/pdf")}
1620
+ data = {"profil": profil}
1621
+ response = self._request("POST", "/traitement/valider-pdf-facturx", files=files, data=data)
1622
+ return response.json()
1623
+
1624
+ def valider_signature_pdf(
1625
+ self,
1626
+ pdf_path: Optional[str] = None,
1627
+ pdf_bytes: Optional[bytes] = None
1628
+ ) -> Dict[str, Any]:
1629
+ """Valide la signature d'un PDF signé.
1630
+
1631
+ Args:
1632
+ pdf_path: Chemin vers le fichier PDF signé
1633
+ pdf_bytes: Contenu PDF en bytes
1634
+
1635
+ Returns:
1636
+ Dict avec: is_signed (bool), signatures (list), etc.
1637
+
1638
+ Example:
1639
+ >>> result = client.valider_signature_pdf("facture_signee.pdf")
1640
+ >>> if result['is_signed']:
1641
+ ... print("PDF signé!")
1642
+ ... for sig in result.get('signatures', []):
1643
+ ... print(f"Signé par: {sig.get('signer_cn')}")
1644
+ """
1645
+ if pdf_path:
1646
+ with open(pdf_path, "rb") as f:
1647
+ pdf_bytes = f.read()
1648
+ if not pdf_bytes:
1649
+ raise ValueError("pdf_path ou pdf_bytes requis")
1650
+
1651
+ files = {"fichier_pdf": ("document.pdf", pdf_bytes, "application/pdf")}
1652
+ response = self._request("POST", "/traitement/valider-signature-pdf", files=files)
1653
+ return response.json()
1654
+
1655
+ # ==================== Signature ====================
1656
+
1657
+ def signer_pdf(
1658
+ self,
1659
+ pdf_path: Optional[str] = None,
1660
+ pdf_bytes: Optional[bytes] = None,
1661
+ raison: Optional[str] = None,
1662
+ localisation: Optional[str] = None,
1663
+ contact: Optional[str] = None,
1664
+ use_pades_lt: bool = False,
1665
+ use_timestamp: bool = True,
1666
+ output_path: Optional[str] = None
1667
+ ) -> Union[bytes, str]:
1668
+ """Signe un PDF avec le certificat configuré côté serveur.
1669
+
1670
+ Le certificat doit être préalablement configuré dans Django Admin
1671
+ pour le client identifié par le client_uid du JWT.
1672
+
1673
+ Args:
1674
+ pdf_path: Chemin vers le PDF à signer
1675
+ pdf_bytes: Contenu PDF en bytes
1676
+ raison: Raison de la signature (optionnel)
1677
+ localisation: Lieu de signature (optionnel)
1678
+ contact: Email de contact (optionnel)
1679
+ use_pades_lt: Activer PAdES-B-LT archivage long terme (défaut: False)
1680
+ use_timestamp: Activer l'horodatage RFC 3161 (défaut: True)
1681
+ output_path: Si fourni, sauvegarde le PDF signé à ce chemin
1682
+
1683
+ Returns:
1684
+ bytes du PDF signé, ou chemin si output_path fourni
1685
+
1686
+ Example:
1687
+ >>> pdf_signe = client.signer_pdf(
1688
+ ... pdf_path="facture.pdf",
1689
+ ... raison="Conformité Factur-X",
1690
+ ... output_path="facture_signee.pdf"
1691
+ ... )
1692
+ """
1693
+ if pdf_path:
1694
+ with open(pdf_path, "rb") as f:
1695
+ pdf_bytes = f.read()
1696
+
1697
+ if not pdf_bytes:
1698
+ raise ValueError("pdf_path ou pdf_bytes requis")
1699
+
1700
+ files = {
1701
+ "fichier_pdf": ("document.pdf", pdf_bytes, "application/pdf"),
1702
+ }
1703
+ data: Dict[str, Any] = {
1704
+ "use_pades_lt": str(use_pades_lt).lower(),
1705
+ "use_timestamp": str(use_timestamp).lower(),
1706
+ }
1707
+ if raison:
1708
+ data["raison"] = raison
1709
+ if localisation:
1710
+ data["localisation"] = localisation
1711
+ if contact:
1712
+ data["contact"] = contact
1713
+
1714
+ response = self._request("POST", "/traitement/signer-pdf", files=files, data=data)
1715
+ result = response.json()
1716
+
1717
+ # L'API retourne du JSON avec pdf_signe_base64
1718
+ pdf_signe_b64 = result.get("pdf_signe_base64")
1719
+ if not pdf_signe_b64:
1720
+ raise FactPulseValidationError("Réponse de signature invalide")
1721
+
1722
+ import base64
1723
+ pdf_signe = base64.b64decode(pdf_signe_b64)
1724
+
1725
+ if output_path:
1726
+ with open(output_path, "wb") as f:
1727
+ f.write(pdf_signe)
1728
+ return output_path
1729
+
1730
+ return pdf_signe
1731
+
1732
+ def generer_certificat_test(
1733
+ self,
1734
+ cn: str = "Test Organisation",
1735
+ organisation: str = "Test Organisation",
1736
+ email: str = "test@example.com",
1737
+ duree_jours: int = 365,
1738
+ taille_cle: int = 2048,
1739
+ ) -> Dict[str, Any]:
1740
+ """Génère un certificat de test pour la signature (NON PRODUCTION).
1741
+
1742
+ Le certificat généré doit ensuite être configuré dans Django Admin.
1743
+
1744
+ Args:
1745
+ cn: Common Name du certificat
1746
+ organisation: Nom de l'organisation
1747
+ email: Email associé au certificat
1748
+ duree_jours: Durée de validité en jours (défaut: 365)
1749
+ taille_cle: Taille de la clé RSA (2048 ou 4096)
1750
+
1751
+ Returns:
1752
+ Dict avec certificat_pem, cle_privee_pem, pkcs12_base64, etc.
1753
+
1754
+ Example:
1755
+ >>> result = client.generer_certificat_test(
1756
+ ... cn="Ma Société - Cachet",
1757
+ ... organisation="Ma Société SAS",
1758
+ ... email="contact@masociete.fr",
1759
+ ... )
1760
+ >>> print(result["certificat_pem"])
1761
+ """
1762
+ data = {
1763
+ "cn": cn,
1764
+ "organisation": organisation,
1765
+ "email": email,
1766
+ "duree_jours": duree_jours,
1767
+ "taille_cle": taille_cle,
1768
+ }
1769
+ response = self._request("POST", "/traitement/generer-certificat-test", json_data=data)
1770
+ return response.json()
1771
+
1772
+ # ==================== Workflow complet ====================
1773
+
1774
+ def generer_facturx_complet(
1775
+ self,
1776
+ facture: Dict[str, Any],
1777
+ pdf_source_path: Optional[str] = None,
1778
+ pdf_source_bytes: Optional[bytes] = None,
1779
+ profil: str = "EN16931",
1780
+ valider: bool = True,
1781
+ signer: bool = False,
1782
+ soumettre_afnor: bool = False,
1783
+ afnor_flow_name: Optional[str] = None,
1784
+ afnor_tracking_id: Optional[str] = None,
1785
+ output_path: Optional[str] = None,
1786
+ timeout: int = 120000
1787
+ ) -> Dict[str, Any]:
1788
+ """Génère un PDF Factur-X complet avec validation, signature et soumission optionnelles.
1789
+
1790
+ Cette méthode enchaîne automatiquement:
1791
+ 1. Génération du PDF Factur-X
1792
+ 2. Validation (optionnelle)
1793
+ 3. Signature (optionnelle, utilise le certificat côté serveur)
1794
+ 4. Soumission à la PDP AFNOR (optionnelle)
1795
+
1796
+ Note: La signature utilise le certificat configuré dans Django Admin
1797
+ pour le client identifié par le client_uid du JWT.
1798
+
1799
+ Args:
1800
+ facture: Données de la facture (format FactureFacturX)
1801
+ pdf_source_path: Chemin vers le PDF source
1802
+ pdf_source_bytes: PDF source en bytes
1803
+ profil: Profil Factur-X (MINIMUM, BASIC, EN16931, EXTENDED)
1804
+ valider: Si True, valide le PDF généré
1805
+ signer: Si True, signe le PDF (certificat côté serveur)
1806
+ soumettre_afnor: Si True, soumet le PDF à la PDP AFNOR
1807
+ afnor_flow_name: Nom du flux AFNOR (défaut: "Facture {numero_facture}")
1808
+ afnor_tracking_id: Tracking ID AFNOR (défaut: numero_facture)
1809
+ output_path: Chemin de sortie pour le PDF final
1810
+ timeout: Timeout en ms pour le polling
1811
+
1812
+ Returns:
1813
+ Dict avec:
1814
+ - pdf_bytes: bytes du PDF final
1815
+ - pdf_path: chemin si output_path fourni
1816
+ - validation: résultat de validation si valider=True
1817
+ - signature: infos signature si signer=True
1818
+ - afnor: résultat soumission AFNOR si soumettre_afnor=True
1819
+
1820
+ Example:
1821
+ >>> result = client.generer_facturx_complet(
1822
+ ... facture=ma_facture,
1823
+ ... pdf_source_path="devis.pdf",
1824
+ ... profil="EN16931",
1825
+ ... valider=True,
1826
+ ... signer=True,
1827
+ ... soumettre_afnor=True,
1828
+ ... output_path="facture_finale.pdf"
1829
+ ... )
1830
+ >>> if result['validation']['valide']:
1831
+ ... print(f"Facture soumise! Flow ID: {result['afnor']['flowId']}")
1832
+ """
1833
+ result: Dict[str, Any] = {}
1834
+
1835
+ # 1. Génération
1836
+ if pdf_source_path:
1837
+ with open(pdf_source_path, "rb") as f:
1838
+ pdf_source_bytes = f.read()
1839
+
1840
+ pdf_bytes = self.generer_facturx(
1841
+ facture_data=facture,
1842
+ pdf_source=pdf_source_bytes,
1843
+ profil=profil,
1844
+ timeout=timeout
1845
+ )
1846
+ result["pdf_bytes"] = pdf_bytes
1847
+
1848
+ # 2. Validation
1849
+ if valider:
1850
+ validation = self.valider_pdf_facturx(pdf_bytes=pdf_bytes, profil=profil)
1851
+ result["validation"] = validation
1852
+ if not validation.get("est_conforme", False):
1853
+ # Retourne quand même le résultat mais avec les erreurs
1854
+ if output_path:
1855
+ with open(output_path, "wb") as f:
1856
+ f.write(pdf_bytes)
1857
+ result["pdf_path"] = output_path
1858
+ return result
1859
+
1860
+ # 3. Signature (utilise le certificat côté serveur)
1861
+ if signer:
1862
+ pdf_bytes = self.signer_pdf(pdf_bytes=pdf_bytes)
1863
+ result["pdf_bytes"] = pdf_bytes
1864
+ result["signature"] = {"signe": True}
1865
+
1866
+ # 4. Soumission AFNOR
1867
+ if soumettre_afnor:
1868
+ numero_facture = facture.get("numeroFacture", facture.get("numero_facture", "FACTURE"))
1869
+ flow_name = afnor_flow_name or f"Facture {numero_facture}"
1870
+ tracking_id = afnor_tracking_id or numero_facture
1871
+
1872
+ # Soumission directe avec bytes (plus de fichier temporaire nécessaire)
1873
+ afnor_result = self.soumettre_facture_afnor(
1874
+ flow_name=flow_name,
1875
+ pdf_bytes=pdf_bytes,
1876
+ pdf_filename=f"{numero_facture}.pdf",
1877
+ tracking_id=tracking_id,
1878
+ )
1879
+ result["afnor"] = afnor_result
1880
+
1881
+ # Sauvegarde finale
1882
+ if output_path:
1883
+ with open(output_path, "wb") as f:
1884
+ f.write(pdf_bytes)
1885
+ result["pdf_path"] = output_path
1886
+
1887
+ return result