factpulse 1.0.9__py3-none-any.whl → 3.0.7__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 (269) hide show
  1. factpulse/__init__.py +275 -203
  2. factpulse/api/__init__.py +5 -3
  3. factpulse/api/afnorpdppa_api.py +559 -9
  4. factpulse/api/afnorpdppa_directory_service_api.py +4313 -66
  5. factpulse/api/afnorpdppa_flow_service_api.py +23 -23
  6. factpulse/api/chorus_pro_api.py +362 -404
  7. factpulse/api/{signature_lectronique_api.py → document_conversion_api.py} +519 -371
  8. factpulse/api/health_api.py +526 -0
  9. factpulse/api/invoice_processing_api.py +3437 -0
  10. factpulse/api/pdfxml_verification_api.py +1719 -0
  11. factpulse/api/{sant_api.py → user_api.py} +18 -17
  12. factpulse/api_client.py +6 -6
  13. factpulse/configuration.py +6 -4
  14. factpulse/exceptions.py +8 -5
  15. factpulse/models/__init__.py +133 -99
  16. factpulse/models/acknowledgment_status.py +38 -0
  17. factpulse/models/additional_document.py +115 -0
  18. factpulse/models/afnor_credentials.py +106 -0
  19. factpulse/models/afnor_destination.py +127 -0
  20. factpulse/models/afnor_health_check_response.py +91 -0
  21. factpulse/models/afnor_result.py +105 -0
  22. factpulse/models/allowance_charge.py +147 -0
  23. factpulse/models/allowance_reason_code.py +42 -0
  24. factpulse/models/allowance_total_amount.py +145 -0
  25. factpulse/models/amount.py +139 -0
  26. factpulse/models/amount_due.py +139 -0
  27. factpulse/models/api_error.py +104 -0
  28. factpulse/models/async_task_status.py +97 -0
  29. factpulse/models/base_amount.py +145 -0
  30. factpulse/models/bounding_box_schema.py +100 -0
  31. factpulse/models/celery_status.py +40 -0
  32. factpulse/models/certificate_info_response.py +24 -24
  33. factpulse/models/charge_total_amount.py +145 -0
  34. factpulse/models/chorus_pro_destination.py +108 -0
  35. factpulse/models/chorus_pro_result.py +101 -0
  36. factpulse/models/contact.py +113 -0
  37. factpulse/models/convert_error_response.py +105 -0
  38. factpulse/models/convert_pending_input_response.py +114 -0
  39. factpulse/models/convert_resume_request.py +87 -0
  40. factpulse/models/convert_success_response.py +126 -0
  41. factpulse/models/convert_validation_failed_response.py +120 -0
  42. factpulse/models/delivery_party.py +121 -0
  43. factpulse/models/destination.py +27 -27
  44. factpulse/models/document_type_info.py +91 -0
  45. factpulse/models/electronic_address.py +90 -0
  46. factpulse/models/enriched_invoice_info.py +133 -0
  47. factpulse/models/error_level.py +37 -0
  48. factpulse/models/error_source.py +43 -0
  49. factpulse/models/extraction_info.py +93 -0
  50. factpulse/models/factur_x_invoice.py +320 -0
  51. factpulse/models/factur_x_profile.py +39 -0
  52. factpulse/models/factur_xpdf_info.py +91 -0
  53. factpulse/models/facture_electronique_rest_api_schemas_chorus_pro_chorus_pro_credentials.py +95 -0
  54. factpulse/models/facture_electronique_rest_api_schemas_processing_chorus_pro_credentials.py +115 -0
  55. factpulse/models/field_status.py +40 -0
  56. factpulse/models/file_info.py +94 -0
  57. factpulse/models/files_info.py +106 -0
  58. factpulse/models/flow_direction.py +37 -0
  59. factpulse/models/flow_profile.py +38 -0
  60. factpulse/models/flow_summary.py +131 -0
  61. factpulse/models/flow_syntax.py +40 -0
  62. factpulse/models/flow_type.py +40 -0
  63. factpulse/models/generate_certificate_request.py +26 -26
  64. factpulse/models/generate_certificate_response.py +15 -15
  65. factpulse/models/get_chorus_pro_id_request.py +100 -0
  66. factpulse/models/get_chorus_pro_id_response.py +98 -0
  67. factpulse/models/get_invoice_request.py +98 -0
  68. factpulse/models/get_invoice_response.py +142 -0
  69. factpulse/models/get_structure_request.py +100 -0
  70. factpulse/models/get_structure_response.py +142 -0
  71. factpulse/models/global_allowance_amount.py +139 -0
  72. factpulse/models/gross_unit_price.py +145 -0
  73. factpulse/models/http_validation_error.py +2 -2
  74. factpulse/models/incoming_invoice.py +196 -0
  75. factpulse/models/incoming_supplier.py +144 -0
  76. factpulse/models/invoice_format.py +38 -0
  77. factpulse/models/invoice_line.py +354 -0
  78. factpulse/models/invoice_line_allowance_amount.py +145 -0
  79. factpulse/models/invoice_note.py +94 -0
  80. factpulse/models/invoice_references.py +194 -0
  81. factpulse/models/invoice_status.py +96 -0
  82. factpulse/models/invoice_totals.py +177 -0
  83. factpulse/models/invoice_totals_prepayment.py +145 -0
  84. factpulse/models/invoice_type_code.py +51 -0
  85. factpulse/models/invoicing_framework.py +110 -0
  86. factpulse/models/invoicing_framework_code.py +39 -0
  87. factpulse/models/line_net_amount.py +145 -0
  88. factpulse/models/line_total_amount.py +145 -0
  89. factpulse/models/mandatory_note_schema.py +124 -0
  90. factpulse/models/manual_rate.py +139 -0
  91. factpulse/models/manual_vat_rate.py +139 -0
  92. factpulse/models/missing_field.py +107 -0
  93. factpulse/models/operation_nature.py +49 -0
  94. factpulse/models/output_format.py +37 -0
  95. factpulse/models/page_dimensions_schema.py +89 -0
  96. factpulse/models/payee.py +168 -0
  97. factpulse/models/payment_card.py +99 -0
  98. factpulse/models/payment_means.py +41 -0
  99. factpulse/models/pdf_validation_result_api.py +169 -0
  100. factpulse/models/pdp_credentials.py +20 -13
  101. factpulse/models/percentage.py +145 -0
  102. factpulse/models/postal_address.py +134 -0
  103. factpulse/models/price_allowance_amount.py +145 -0
  104. factpulse/models/price_basis_quantity.py +145 -0
  105. factpulse/models/processing_options.py +94 -0
  106. factpulse/models/product_characteristic.py +89 -0
  107. factpulse/models/product_classification.py +101 -0
  108. factpulse/models/quantity.py +139 -0
  109. factpulse/models/recipient.py +167 -0
  110. factpulse/models/rounding_amount.py +145 -0
  111. factpulse/models/scheme_id.py +14 -8
  112. factpulse/models/search_flow_request.py +143 -0
  113. factpulse/models/search_flow_response.py +101 -0
  114. factpulse/models/search_services_response.py +101 -0
  115. factpulse/models/search_structure_request.py +119 -0
  116. factpulse/models/search_structure_response.py +101 -0
  117. factpulse/models/signature_info.py +6 -6
  118. factpulse/models/signature_info_api.py +122 -0
  119. factpulse/models/signature_parameters.py +133 -0
  120. factpulse/models/simplified_invoice_data.py +124 -0
  121. factpulse/models/structure_info.py +14 -14
  122. factpulse/models/structure_parameters.py +91 -0
  123. factpulse/models/structure_service.py +93 -0
  124. factpulse/models/submission_mode.py +38 -0
  125. factpulse/models/submit_complete_invoice_request.py +116 -0
  126. factpulse/models/submit_complete_invoice_response.py +145 -0
  127. factpulse/models/submit_flow_request.py +123 -0
  128. factpulse/models/submit_flow_response.py +109 -0
  129. factpulse/models/submit_gross_amount.py +139 -0
  130. factpulse/models/submit_invoice_request.py +176 -0
  131. factpulse/models/submit_invoice_response.py +103 -0
  132. factpulse/models/submit_net_amount.py +139 -0
  133. factpulse/models/submit_vat_amount.py +139 -0
  134. factpulse/models/supplementary_attachment.py +95 -0
  135. factpulse/models/supplier.py +225 -0
  136. factpulse/models/task_response.py +87 -0
  137. factpulse/models/tax_representative.py +95 -0
  138. factpulse/models/taxable_amount.py +139 -0
  139. factpulse/models/total_gross_amount.py +139 -0
  140. factpulse/models/total_net_amount.py +139 -0
  141. factpulse/models/total_vat_amount.py +139 -0
  142. factpulse/models/unit_net_price.py +139 -0
  143. factpulse/models/unit_of_measure.py +41 -0
  144. factpulse/models/validation_error.py +2 -2
  145. factpulse/models/validation_error_detail.py +107 -0
  146. factpulse/models/validation_error_loc_inner.py +2 -2
  147. factpulse/models/validation_error_response.py +87 -0
  148. factpulse/models/validation_info.py +105 -0
  149. factpulse/models/validation_success_response.py +87 -0
  150. factpulse/models/vat_accounting_code.py +39 -0
  151. factpulse/models/vat_amount.py +139 -0
  152. factpulse/models/vat_category.py +44 -0
  153. factpulse/models/vat_line.py +140 -0
  154. factpulse/models/vat_point_date_code.py +38 -0
  155. factpulse/models/vat_rate.py +145 -0
  156. factpulse/models/verification_success_response.py +135 -0
  157. factpulse/models/verified_field_schema.py +129 -0
  158. factpulse/rest.py +9 -4
  159. factpulse-3.0.7.dist-info/METADATA +292 -0
  160. factpulse-3.0.7.dist-info/RECORD +168 -0
  161. factpulse-3.0.7.dist-info/top_level.txt +2 -0
  162. factpulse_helpers/__init__.py +96 -0
  163. factpulse_helpers/client.py +2111 -0
  164. factpulse_helpers/exceptions.py +253 -0
  165. factpulse/api/processing_endpoints_unifis_api.py +0 -592
  166. factpulse/api/traitement_facture_api.py +0 -3439
  167. factpulse/api/utilisateur_api.py +0 -282
  168. factpulse/models/adresse_electronique.py +0 -90
  169. factpulse/models/adresse_postale.py +0 -120
  170. factpulse/models/body_ajouter_fichier_api_v1_chorus_pro_transverses_ajouter_fichier_post.py +0 -104
  171. factpulse/models/body_completer_facture_api_v1_chorus_pro_factures_completer_post.py +0 -104
  172. factpulse/models/body_lister_services_structure_api_v1_chorus_pro_structures_id_structure_cpp_services_get.py +0 -102
  173. factpulse/models/body_rechercher_factures_destinataire_api_v1_chorus_pro_factures_rechercher_destinataire_post.py +0 -104
  174. factpulse/models/body_rechercher_factures_fournisseur_api_v1_chorus_pro_factures_rechercher_fournisseur_post.py +0 -104
  175. factpulse/models/body_recycler_facture_api_v1_chorus_pro_factures_recycler_post.py +0 -104
  176. factpulse/models/body_telecharger_groupe_factures_api_v1_chorus_pro_factures_telecharger_groupe_post.py +0 -104
  177. factpulse/models/body_traiter_facture_recue_api_v1_chorus_pro_factures_traiter_facture_recue_post.py +0 -104
  178. factpulse/models/body_valideur_consulter_facture_api_v1_chorus_pro_factures_valideur_consulter_post.py +0 -104
  179. factpulse/models/body_valideur_rechercher_factures_api_v1_chorus_pro_factures_valideur_rechercher_post.py +0 -104
  180. factpulse/models/body_valideur_traiter_facture_api_v1_chorus_pro_factures_valideur_traiter_post.py +0 -104
  181. factpulse/models/cadre_de_facturation.py +0 -102
  182. factpulse/models/categorie_tva.py +0 -44
  183. factpulse/models/chorus_pro_credentials.py +0 -95
  184. factpulse/models/code_cadre_facturation.py +0 -39
  185. factpulse/models/code_raison_reduction.py +0 -42
  186. factpulse/models/consulter_facture_request.py +0 -98
  187. factpulse/models/consulter_facture_response.py +0 -142
  188. factpulse/models/consulter_structure_request.py +0 -100
  189. factpulse/models/consulter_structure_response.py +0 -142
  190. factpulse/models/credentials_afnor.py +0 -106
  191. factpulse/models/credentials_chorus_pro.py +0 -115
  192. factpulse/models/destinataire.py +0 -116
  193. factpulse/models/destination_afnor.py +0 -127
  194. factpulse/models/destination_chorus_pro.py +0 -108
  195. factpulse/models/direction_flux.py +0 -37
  196. factpulse/models/donnees_facture_simplifiees.py +0 -124
  197. factpulse/models/facture_enrichie_info_input.py +0 -123
  198. factpulse/models/facture_enrichie_info_output.py +0 -133
  199. factpulse/models/facture_factur_x.py +0 -173
  200. factpulse/models/flux_resume.py +0 -131
  201. factpulse/models/format_sortie.py +0 -37
  202. factpulse/models/fournisseur.py +0 -146
  203. factpulse/models/information_signature_api.py +0 -122
  204. factpulse/models/ligne_de_poste.py +0 -188
  205. factpulse/models/ligne_de_poste_montant_remise_ht.py +0 -145
  206. factpulse/models/ligne_de_poste_montant_total_ligne_ht.py +0 -145
  207. factpulse/models/ligne_de_poste_taux_tva_manuel.py +0 -145
  208. factpulse/models/ligne_de_tva.py +0 -118
  209. factpulse/models/mode_depot.py +0 -38
  210. factpulse/models/mode_paiement.py +0 -41
  211. factpulse/models/montant_ht_total.py +0 -139
  212. factpulse/models/montant_total.py +0 -138
  213. factpulse/models/montant_total_acompte.py +0 -145
  214. factpulse/models/montant_total_montant_remise_globale_ttc.py +0 -145
  215. factpulse/models/montant_ttc_total.py +0 -139
  216. factpulse/models/montant_tva.py +0 -139
  217. factpulse/models/montantapayer.py +0 -139
  218. factpulse/models/montantbaseht.py +0 -139
  219. factpulse/models/montanthttotal.py +0 -139
  220. factpulse/models/montantttctotal.py +0 -139
  221. factpulse/models/montanttva.py +0 -139
  222. factpulse/models/montanttva1.py +0 -139
  223. factpulse/models/montantunitaireht.py +0 -139
  224. factpulse/models/obtenir_id_chorus_pro_request.py +0 -100
  225. factpulse/models/obtenir_id_chorus_pro_response.py +0 -98
  226. factpulse/models/options_processing.py +0 -94
  227. factpulse/models/parametres_signature.py +0 -133
  228. factpulse/models/parametres_structure.py +0 -91
  229. factpulse/models/pdf_factur_x_info.py +0 -91
  230. factpulse/models/piece_jointe_complementaire.py +0 -95
  231. factpulse/models/profil_api.py +0 -39
  232. factpulse/models/profil_flux.py +0 -38
  233. factpulse/models/quantite.py +0 -139
  234. factpulse/models/quota_info.py +0 -95
  235. factpulse/models/rechercher_services_response.py +0 -101
  236. factpulse/models/rechercher_structure_request.py +0 -119
  237. factpulse/models/rechercher_structure_response.py +0 -101
  238. factpulse/models/references.py +0 -124
  239. factpulse/models/reponse_healthcheck_afnor.py +0 -91
  240. factpulse/models/reponse_recherche_flux.py +0 -101
  241. factpulse/models/reponse_soumission_flux.py +0 -109
  242. factpulse/models/reponse_tache.py +0 -87
  243. factpulse/models/reponse_validation_erreur.py +0 -87
  244. factpulse/models/reponse_validation_succes.py +0 -87
  245. factpulse/models/requete_recherche_flux.py +0 -143
  246. factpulse/models/requete_soumission_flux.py +0 -123
  247. factpulse/models/resultat_afnor.py +0 -105
  248. factpulse/models/resultat_chorus_pro.py +0 -101
  249. factpulse/models/resultat_validation_pdfapi.py +0 -169
  250. factpulse/models/service_structure.py +0 -93
  251. factpulse/models/soumettre_facture_complete_request.py +0 -116
  252. factpulse/models/soumettre_facture_complete_response.py +0 -145
  253. factpulse/models/soumettre_facture_request.py +0 -164
  254. factpulse/models/soumettre_facture_response.py +0 -103
  255. factpulse/models/statut_acquittement.py +0 -38
  256. factpulse/models/statut_facture.py +0 -96
  257. factpulse/models/statut_tache.py +0 -99
  258. factpulse/models/syntaxe_flux.py +0 -40
  259. factpulse/models/tauxmanuel.py +0 -139
  260. factpulse/models/type_facture.py +0 -37
  261. factpulse/models/type_flux.py +0 -40
  262. factpulse/models/type_tva.py +0 -39
  263. factpulse/models/unite.py +0 -41
  264. factpulse/models/utilisateur.py +0 -128
  265. factpulse-1.0.9.dist-info/METADATA +0 -182
  266. factpulse-1.0.9.dist-info/RECORD +0 -131
  267. factpulse-1.0.9.dist-info/top_level.txt +0 -1
  268. {factpulse-1.0.9.dist-info → factpulse-3.0.7.dist-info}/WHEEL +0 -0
  269. {factpulse-1.0.9.dist-info → factpulse-3.0.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,2111 @@
1
+ """Simplified client for the FactPulse API with built-in JWT authentication and polling."""
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, InvoiceProcessingApi
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 for Decimal and other non-serializable types
29
+ # =============================================================================
30
+
31
+ class DecimalEncoder(json.JSONEncoder):
32
+ """Custom JSON encoder that handles Decimal and other Python types."""
33
+
34
+ def default(self, obj):
35
+ if isinstance(obj, Decimal):
36
+ # Convert to string to preserve monetary precision
37
+ return str(obj)
38
+ if hasattr(obj, "isoformat"):
39
+ # datetime, date, time
40
+ return obj.isoformat()
41
+ if hasattr(obj, "to_dict"):
42
+ # Pydantic models or dataclasses with to_dict
43
+ return obj.to_dict()
44
+ return super().default(obj)
45
+
46
+
47
+ def json_dumps_safe(data: Any, **kwargs) -> str:
48
+ """Serialize to JSON handling Decimal and other Python types.
49
+
50
+ Args:
51
+ data: Data to serialize (dict, list, etc.)
52
+ **kwargs: Additional arguments for json.dumps
53
+
54
+ Returns:
55
+ JSON string
56
+
57
+ Example:
58
+ >>> from decimal import Decimal
59
+ >>> json_dumps_safe({"amount": Decimal("1234.56")})
60
+ '{"amount": "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 - for simplified configuration
69
+ # =============================================================================
70
+
71
+ @dataclass
72
+ class ChorusProCredentials:
73
+ """Chorus Pro credentials for Zero-Trust mode.
74
+
75
+ These credentials are passed in each request and never stored server-side.
76
+
77
+ Attributes:
78
+ piste_client_id: PISTE Client ID (government API portal)
79
+ piste_client_secret: PISTE Client Secret
80
+ chorus_pro_login: Chorus Pro login
81
+ chorus_pro_password: Chorus Pro password
82
+ sandbox: True for sandbox environment, False for 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
+ """Convert to dictionary for 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
+ """AFNOR PDP credentials for Zero-Trust mode.
104
+
105
+ These credentials are passed in each request and never stored server-side.
106
+ The FactPulse API uses these credentials to authenticate with the AFNOR PDP
107
+ and obtain a specific OAuth2 token.
108
+
109
+ Attributes:
110
+ flow_service_url: PDP Flow Service URL (e.g., https://api.pdp.fr/flow/v1)
111
+ token_url: PDP OAuth2 server URL (e.g., https://auth.pdp.fr/oauth/token)
112
+ client_id: PDP OAuth2 Client ID
113
+ client_secret: PDP OAuth2 Client Secret
114
+ directory_service_url: Directory Service URL (optional, derived from 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
+ """Convert to dictionary for 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 for anyOf types - avoids verbosity of generated wrappers
137
+ # =============================================================================
138
+
139
+ def amount(value: Union[str, float, int, Decimal, None]) -> str:
140
+ """Convert a value to an amount string for the API.
141
+
142
+ The FactPulse API accepts amounts as strings or floats.
143
+ This function normalizes to string to guarantee monetary precision.
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 invoice_totals(
157
+ total_excl_tax: Union[str, float, int, Decimal],
158
+ total_vat: Union[str, float, int, Decimal],
159
+ total_incl_tax: Union[str, float, int, Decimal],
160
+ amount_due: Union[str, float, int, Decimal],
161
+ discount_incl_tax: Union[str, float, int, Decimal, None] = None,
162
+ discount_reason: Optional[str] = None,
163
+ prepayment: Union[str, float, int, Decimal, None] = None,
164
+ ) -> Dict[str, Any]:
165
+ """Create a simplified InvoiceTotals object.
166
+
167
+ Avoids having to use wrappers like TotalNetAmount, VatAmount, etc.
168
+ """
169
+ result = {
170
+ "totalNetAmount": amount(total_excl_tax),
171
+ "vatAmount": amount(total_vat),
172
+ "totalGrossAmount": amount(total_incl_tax),
173
+ "amountDue": amount(amount_due),
174
+ }
175
+ if discount_incl_tax is not None:
176
+ result["globalAllowanceAmount"] = amount(discount_incl_tax)
177
+ if discount_reason is not None:
178
+ result["globalAllowanceReason"] = discount_reason
179
+ if prepayment is not None:
180
+ result["prepayment"] = amount(prepayment)
181
+ return result
182
+
183
+
184
+ def invoice_line(
185
+ line_number: int,
186
+ description: str,
187
+ quantity: Union[str, float, int, Decimal],
188
+ unit_price_excl_tax: Union[str, float, int, Decimal],
189
+ line_total_excl_tax: Union[str, float, int, Decimal],
190
+ vat_rate_code: Optional[str] = None,
191
+ vat_rate_value: Union[str, float, int, Decimal, None] = "20.00",
192
+ vat_category: str = "S",
193
+ unit: str = "FORFAIT",
194
+ reference: Optional[str] = None,
195
+ discount_excl_tax: Union[str, float, int, Decimal, None] = None,
196
+ discount_reason_code: Optional[str] = None,
197
+ discount_reason: Optional[str] = None,
198
+ period_start_date: Optional[str] = None,
199
+ period_end_date: Optional[str] = None,
200
+ ) -> Dict[str, Any]:
201
+ """Create an invoice line for the FactPulse API.
202
+
203
+ JSON keys are in camelCase (FactPulse API convention).
204
+ Fields correspond exactly to LigneDePoste in models.py.
205
+
206
+ For VAT rate, you can use either:
207
+ - vat_rate_code: Predefined code (e.g., "TVA20", "TVA10", "TVA5.5")
208
+ - vat_rate_value: Numeric value (e.g., "20.00", 20, 20.0)
209
+
210
+ Args:
211
+ line_number: Line number
212
+ description: Product/service description
213
+ quantity: Quantity
214
+ unit_price_excl_tax: Unit price excl. tax
215
+ line_total_excl_tax: Line total excl. tax
216
+ vat_rate_code: Predefined VAT code (e.g., "TVA20") - optional
217
+ vat_rate_value: VAT rate value (default: "20.00") - used if vat_rate_code not provided
218
+ vat_category: VAT category - S (standard), Z (zero), E (exempt), AE (reverse charge), K (intra-community)
219
+ unit: Billing unit (default: "FORFAIT")
220
+ reference: Item reference
221
+ discount_excl_tax: Discount amount excl. tax (optional)
222
+ discount_reason_code: Discount reason code
223
+ discount_reason: Discount reason description
224
+ period_start_date: Billing period start date (YYYY-MM-DD)
225
+ period_end_date: Billing period end date (YYYY-MM-DD)
226
+ """
227
+ result = {
228
+ "lineNumber": line_number,
229
+ "itemName": description,
230
+ "quantity": amount(quantity),
231
+ "unitNetPrice": amount(unit_price_excl_tax),
232
+ "lineNetAmount": amount(line_total_excl_tax),
233
+ "vatCategory": vat_category,
234
+ "unit": unit,
235
+ }
236
+ # Either vat_rate_code (code) or vat_rate_value (value)
237
+ if vat_rate_code is not None:
238
+ result["vatRate"] = vat_rate_code
239
+ elif vat_rate_value is not None:
240
+ result["manualVatRate"] = amount(vat_rate_value)
241
+ if reference is not None:
242
+ result["reference"] = reference
243
+ if discount_excl_tax is not None:
244
+ result["lineAllowanceAmount"] = amount(discount_excl_tax)
245
+ if discount_reason_code is not None:
246
+ result["allowanceReasonCode"] = discount_reason_code
247
+ if discount_reason is not None:
248
+ result["allowanceReason"] = discount_reason
249
+ if period_start_date is not None:
250
+ result["periodStartDate"] = period_start_date
251
+ if period_end_date is not None:
252
+ result["periodEndDate"] = period_end_date
253
+ return result
254
+
255
+
256
+ def vat_line(
257
+ base_amount_excl_tax: Union[str, float, int, Decimal],
258
+ vat_amount: Union[str, float, int, Decimal],
259
+ rate_code: Optional[str] = None,
260
+ rate_value: Union[str, float, int, Decimal, None] = "20.00",
261
+ category: str = "S",
262
+ ) -> Dict[str, Any]:
263
+ """Create a VAT line for the FactPulse API.
264
+
265
+ JSON keys are in camelCase (FactPulse API convention).
266
+ Fields correspond exactly to LigneDeTVA in models.py.
267
+
268
+ For VAT rate, you can use either:
269
+ - rate_code: Predefined code (e.g., "TVA20", "TVA10", "TVA5.5")
270
+ - rate_value: Numeric value (e.g., "20.00", 20, 20.0)
271
+
272
+ Args:
273
+ base_amount_excl_tax: Base amount excl. tax
274
+ vat_amount: VAT amount
275
+ rate_code: Predefined VAT code (e.g., "TVA20") - optional
276
+ rate_value: VAT rate value (default: "20.00") - used if rate_code not provided
277
+ category: VAT category (default: "S" for standard)
278
+ """
279
+ result = {
280
+ "taxableAmount": amount(base_amount_excl_tax),
281
+ "vatAmount": amount(vat_amount),
282
+ "category": category,
283
+ }
284
+ # Either rate_code (code) or rate_value (value)
285
+ if rate_code is not None:
286
+ result["rate"] = rate_code
287
+ elif rate_value is not None:
288
+ result["manualRate"] = amount(rate_value)
289
+ return result
290
+
291
+
292
+ def postal_address(
293
+ line1: str,
294
+ postal_code: str,
295
+ city: str,
296
+ country: str = "FR",
297
+ line2: Optional[str] = None,
298
+ line3: Optional[str] = None,
299
+ ) -> Dict[str, Any]:
300
+ """Create a postal address for the FactPulse API.
301
+
302
+ Args:
303
+ line1: First address line (number, street)
304
+ postal_code: Postal code
305
+ city: City name
306
+ country: ISO country code (default: "FR")
307
+ line2: Second address line (optional)
308
+ line3: Third address line (optional)
309
+
310
+ Example:
311
+ >>> address = postal_address("123 Example Street", "75001", "Paris")
312
+ """
313
+ result = {
314
+ "lineOne": line1,
315
+ "postalCode": postal_code,
316
+ "city": city,
317
+ "countryCode": country,
318
+ }
319
+ if line2:
320
+ result["lineTwo"] = line2
321
+ if line3:
322
+ result["lineThree"] = line3
323
+ return result
324
+
325
+
326
+ def electronic_address(
327
+ identifier: str,
328
+ scheme_id: str = "0009",
329
+ ) -> Dict[str, Any]:
330
+ """Create an electronic address for the FactPulse API.
331
+
332
+ Args:
333
+ identifier: Address identifier (SIRET, SIREN, etc.)
334
+ scheme_id: Identification scheme (default: "0009" for SIREN)
335
+ - "0009": SIREN
336
+ - "0088": EAN
337
+ - "0096": DUNS
338
+ - "0130": Custom coding
339
+ - "0225": FR - SIRET (French scheme)
340
+
341
+ Example:
342
+ >>> address = electronic_address("12345678901234", "0225") # SIRET
343
+ """
344
+ return {
345
+ "identifier": identifier,
346
+ "schemeId": scheme_id,
347
+ }
348
+
349
+
350
+ def supplier(
351
+ name: str,
352
+ siret: str,
353
+ address_line1: str,
354
+ postal_code: str,
355
+ city: str,
356
+ supplier_id: int = 0,
357
+ siren: Optional[str] = None,
358
+ vat_number: Optional[str] = None,
359
+ iban: Optional[str] = None,
360
+ country: str = "FR",
361
+ address_line2: Optional[str] = None,
362
+ service_code: Optional[int] = None,
363
+ bank_details_code: Optional[int] = None,
364
+ ) -> Dict[str, Any]:
365
+ """Create a supplier (invoice issuer) for the FactPulse API.
366
+
367
+ This function simplifies supplier creation by automatically generating:
368
+ - Structured postal address
369
+ - Electronic address (based on SIRET)
370
+ - SIREN (extracted from SIRET if not provided)
371
+ - Intra-community VAT number (calculated from SIREN if not provided)
372
+
373
+ Args:
374
+ name: Company name / trade name
375
+ siret: SIRET number (14 digits)
376
+ address_line1: First address line
377
+ postal_code: Postal code
378
+ city: City
379
+ supplier_id: Chorus Pro supplier ID (default: 0)
380
+ siren: SIREN number (9 digits) - calculated from SIRET if absent
381
+ vat_number: Intra-community VAT number - calculated if absent
382
+ iban: IBAN for payment
383
+ country: ISO country code (default: "FR")
384
+ address_line2: Second address line (optional)
385
+ service_code: Chorus Pro supplier service ID (optional)
386
+ bank_details_code: Chorus Pro bank details code (optional)
387
+
388
+ Returns:
389
+ Dict ready to be used in an invoice
390
+
391
+ Example:
392
+ >>> s = supplier(
393
+ ... name="My Company SAS",
394
+ ... siret="12345678900001",
395
+ ... address_line1="123 Republic Street",
396
+ ... postal_code="75001",
397
+ ... city="Paris",
398
+ ... iban="FR7630006000011234567890189",
399
+ ... )
400
+ """
401
+ # Auto-calculate SIREN from SIRET
402
+ if not siren and len(siret) == 14:
403
+ siren = siret[:9]
404
+
405
+ # Auto-calculate French intra-community VAT number
406
+ if not vat_number and siren and len(siren) == 9:
407
+ # VAT key = (12 + 3 * (SIREN % 97)) % 97
408
+ try:
409
+ key = (12 + 3 * (int(siren) % 97)) % 97
410
+ vat_number = f"FR{key:02d}{siren}"
411
+ except ValueError:
412
+ pass # Non-numeric SIREN, skip
413
+
414
+ result: Dict[str, Any] = {
415
+ "name": name,
416
+ "supplierId": supplier_id,
417
+ "siret": siret,
418
+ "electronicAddress": electronic_address(siret, "0225"),
419
+ "postalAddress": postal_address(address_line1, postal_code, city, country, address_line2),
420
+ }
421
+
422
+ if siren:
423
+ result["siren"] = siren
424
+ if vat_number:
425
+ result["vatNumber"] = vat_number
426
+ if iban:
427
+ result["iban"] = iban
428
+ if service_code:
429
+ result["supplierServiceId"] = service_code
430
+ if bank_details_code:
431
+ result["supplierBankDetailsCode"] = bank_details_code
432
+
433
+ return result
434
+
435
+
436
+ def recipient(
437
+ name: str,
438
+ siret: str,
439
+ address_line1: str,
440
+ postal_code: str,
441
+ city: str,
442
+ siren: Optional[str] = None,
443
+ country: str = "FR",
444
+ address_line2: Optional[str] = None,
445
+ service_code: Optional[str] = None,
446
+ ) -> Dict[str, Any]:
447
+ """Create a recipient (invoice customer) for the FactPulse API.
448
+
449
+ This function simplifies recipient creation by automatically generating:
450
+ - Structured postal address
451
+ - Electronic address (based on SIRET)
452
+ - SIREN (extracted from SIRET if not provided)
453
+
454
+ Args:
455
+ name: Company name / trade name
456
+ siret: SIRET number (14 digits)
457
+ address_line1: First address line
458
+ postal_code: Postal code
459
+ city: City
460
+ siren: SIREN number (9 digits) - calculated from SIRET if absent
461
+ country: ISO country code (default: "FR")
462
+ address_line2: Second address line (optional)
463
+ service_code: Recipient service code (optional)
464
+
465
+ Returns:
466
+ Dict ready to be used in an invoice
467
+
468
+ Example:
469
+ >>> r = recipient(
470
+ ... name="Client SARL",
471
+ ... siret="98765432109876",
472
+ ... address_line1="456 Champs Avenue",
473
+ ... postal_code="69001",
474
+ ... city="Lyon",
475
+ ... )
476
+ """
477
+ # Auto-calculate SIREN from SIRET
478
+ if not siren and len(siret) == 14:
479
+ siren = siret[:9]
480
+
481
+ result: Dict[str, Any] = {
482
+ "name": name,
483
+ "siret": siret,
484
+ "electronicAddress": electronic_address(siret, "0225"),
485
+ "postalAddress": postal_address(address_line1, postal_code, city, country, address_line2),
486
+ }
487
+
488
+ if siren:
489
+ result["siren"] = siren
490
+ if service_code:
491
+ result["executingServiceCode"] = service_code
492
+
493
+ return result
494
+
495
+
496
+ def payee(
497
+ name: str,
498
+ siret: Optional[str] = None,
499
+ siren: Optional[str] = None,
500
+ iban: Optional[str] = None,
501
+ bic: Optional[str] = None,
502
+ ) -> Dict[str, Any]:
503
+ """Create a payee (factor) for factoring.
504
+
505
+ The payee (BG-10 / PayeeTradeParty) is used when payment must be made
506
+ to a third party different from the supplier, typically a factor
507
+ (factoring company).
508
+
509
+ For factored invoices, you must also:
510
+ - Use a factored document type (393, 396, 501, 502, 472, 473)
511
+ - Add an ACC note with the assignment clause
512
+ - The payee's IBAN will be used for payment
513
+
514
+ Args:
515
+ name: Factor's company name (BT-59)
516
+ siret: Factor's SIRET number (BT-60, schemeID 0009) - 14 digits
517
+ siren: Factor's SIREN number (BT-61, schemeID 0002) - calculated from SIRET if absent
518
+ iban: Factor's IBAN - to receive payment
519
+ bic: Factor's bank BIC (optional)
520
+
521
+ Returns:
522
+ Dict ready to be used in a factored invoice
523
+
524
+ Example:
525
+ >>> # Simple factored invoice
526
+ >>> factor = payee(
527
+ ... name="FACTOR SAS",
528
+ ... siret="30000000700033",
529
+ ... iban="FR76 3000 4000 0500 0012 3456 789",
530
+ ... )
531
+ >>> invoice = {
532
+ ... "invoiceNumber": "INV-2025-001-FACT",
533
+ ... "supplier": supplier(...),
534
+ ... "recipient": recipient(...),
535
+ ... "payee": factor, # Factor receives payment
536
+ ... "references": {
537
+ ... "invoiceType": "393", # Factored invoice
538
+ ... ...
539
+ ... },
540
+ ... "notes": [
541
+ ... {
542
+ ... "content": "This receivable has been assigned to FACTOR SAS. Contract n. FACT-2025",
543
+ ... "subjectCode": "ACC", # Mandatory assignment code
544
+ ... },
545
+ ... ...
546
+ ... ],
547
+ ... ...
548
+ ... }
549
+
550
+ See Also:
551
+ - Factoring guide: docs/factoring_guide.md
552
+ - Factored document types: 393 (invoice), 396 (credit note), 501, 502, 472, 473
553
+ - ACC note: Mandatory factoring assignment clause
554
+ """
555
+ # Auto-calculate SIREN from SIRET
556
+ if not siren and siret and len(siret) == 14:
557
+ siren = siret[:9]
558
+
559
+ result: Dict[str, Any] = {
560
+ "name": name,
561
+ }
562
+
563
+ if siret:
564
+ result["siret"] = siret
565
+ if siren:
566
+ result["siren"] = siren
567
+ if iban:
568
+ result["iban"] = iban
569
+ if bic:
570
+ result["bic"] = bic
571
+
572
+ return result
573
+
574
+
575
+
576
+
577
+ class FactPulseClient:
578
+ """Simplified client for the FactPulse API.
579
+
580
+ Handles JWT authentication, asynchronous task polling,
581
+ and allows configuring Chorus Pro / AFNOR credentials at initialization.
582
+ """
583
+
584
+ DEFAULT_API_URL = "https://factpulse.fr"
585
+ DEFAULT_POLLING_INTERVAL = 2000 # ms
586
+ DEFAULT_POLLING_TIMEOUT = 120000 # ms
587
+ DEFAULT_MAX_RETRIES = 1
588
+
589
+ def __init__(
590
+ self,
591
+ email: str,
592
+ password: str,
593
+ api_url: Optional[str] = None,
594
+ client_uid: Optional[str] = None,
595
+ chorus_credentials: Optional[ChorusProCredentials] = None,
596
+ afnor_credentials: Optional[AFNORCredentials] = None,
597
+ polling_interval: Optional[int] = None,
598
+ polling_timeout: Optional[int] = None,
599
+ max_retries: Optional[int] = None,
600
+ ):
601
+ self.email = email
602
+ self.password = password
603
+ self.api_url = (api_url or self.DEFAULT_API_URL).rstrip("/")
604
+ self.client_uid = client_uid
605
+ self.chorus_credentials = chorus_credentials
606
+ self.afnor_credentials = afnor_credentials
607
+ self.polling_interval = polling_interval or self.DEFAULT_POLLING_INTERVAL
608
+ self.polling_timeout = polling_timeout or self.DEFAULT_POLLING_TIMEOUT
609
+ self.max_retries = max_retries if max_retries is not None else self.DEFAULT_MAX_RETRIES
610
+
611
+ self._access_token: Optional[str] = None
612
+ self._refresh_token: Optional[str] = None
613
+ self._token_expires_at: Optional[datetime] = None
614
+ self._api_client: Optional[ApiClient] = None
615
+
616
+ def get_chorus_credentials_for_api(self) -> Optional[Dict[str, Any]]:
617
+ """Return Chorus Pro credentials in API format."""
618
+ return self.chorus_credentials.to_dict() if self.chorus_credentials else None
619
+
620
+ def get_afnor_credentials_for_api(self) -> Optional[Dict[str, Any]]:
621
+ """Return AFNOR credentials in API format."""
622
+ return self.afnor_credentials.to_dict() if self.afnor_credentials else None
623
+
624
+ # Shorter aliases for convenience
625
+ def get_chorus_pro_credentials(self) -> Optional[Dict[str, Any]]:
626
+ """Alias for get_chorus_credentials_for_api()."""
627
+ return self.get_chorus_credentials_for_api()
628
+
629
+ def get_afnor_credentials(self) -> Optional[Dict[str, Any]]:
630
+ """Alias for get_afnor_credentials_for_api()."""
631
+ return self.get_afnor_credentials_for_api()
632
+
633
+ def _obtain_token(self) -> Dict[str, str]:
634
+ """Obtain a new JWT token."""
635
+ token_url = f"{self.api_url}/api/token/"
636
+ payload = {"username": self.email, "password": self.password}
637
+ if self.client_uid:
638
+ payload["client_uid"] = self.client_uid
639
+
640
+ try:
641
+ response = requests.post(token_url, json=payload, timeout=30)
642
+ response.raise_for_status()
643
+ logger.info("JWT token obtained for %s", self.email)
644
+ return response.json()
645
+ except requests.RequestException as e:
646
+ error_detail = ""
647
+ if hasattr(e, "response") and e.response is not None:
648
+ try:
649
+ error_detail = e.response.json().get("detail", str(e))
650
+ except Exception:
651
+ error_detail = str(e)
652
+ raise FactPulseAuthError(f"Unable to obtain JWT token: {error_detail or e}")
653
+
654
+ def _refresh_access_token(self) -> str:
655
+ """Refresh the access token."""
656
+ if not self._refresh_token:
657
+ raise FactPulseAuthError("No refresh token available")
658
+
659
+ refresh_url = f"{self.api_url}/api/token/refresh/"
660
+ try:
661
+ response = requests.post(
662
+ refresh_url, json={"refresh": self._refresh_token}, timeout=30
663
+ )
664
+ response.raise_for_status()
665
+ logger.info("Token refreshed successfully")
666
+ return response.json()["access"]
667
+ except requests.RequestException:
668
+ logger.warning("Refresh failed, obtaining new token")
669
+ tokens = self._obtain_token()
670
+ self._refresh_token = tokens["refresh"]
671
+ return tokens["access"]
672
+
673
+ def ensure_authenticated(self, force_refresh: bool = False) -> None:
674
+ """Ensure the client is authenticated."""
675
+ now = datetime.now()
676
+
677
+ if force_refresh or not self._access_token or not self._token_expires_at or now >= self._token_expires_at:
678
+ if self._refresh_token and self._token_expires_at and not force_refresh:
679
+ try:
680
+ self._access_token = self._refresh_access_token()
681
+ self._token_expires_at = now + timedelta(minutes=28)
682
+ return
683
+ except FactPulseAuthError:
684
+ pass
685
+
686
+ tokens = self._obtain_token()
687
+ self._access_token = tokens["access"]
688
+ self._refresh_token = tokens["refresh"]
689
+ self._token_expires_at = now + timedelta(minutes=28)
690
+
691
+ def reset_auth(self) -> None:
692
+ """Reset authentication."""
693
+ self._access_token = None
694
+ self._refresh_token = None
695
+ self._token_expires_at = None
696
+ self._api_client = None
697
+ logger.info("Authentication reset")
698
+
699
+ def _request(
700
+ self,
701
+ method: str,
702
+ endpoint: str,
703
+ files: Optional[Dict] = None,
704
+ data: Optional[Dict] = None,
705
+ json_data: Optional[Dict] = None,
706
+ ) -> requests.Response:
707
+ """Perform an HTTP request to the FactPulse API.
708
+
709
+ Args:
710
+ method: HTTP method (GET, POST, etc.)
711
+ endpoint: Relative endpoint (e.g., /processing/validate-pdf)
712
+ files: Files for multipart/form-data
713
+ data: Form data
714
+ json_data: JSON data
715
+
716
+ Returns:
717
+ API response
718
+
719
+ Raises:
720
+ FactPulseValidationError: On API error
721
+ """
722
+ self.ensure_authenticated()
723
+ url = f"{self.api_url}/api/v1{endpoint}"
724
+ headers = {"Authorization": f"Bearer {self._access_token}"}
725
+
726
+ try:
727
+ if files:
728
+ response = requests.request(method, url, files=files, data=data, headers=headers, timeout=60)
729
+ elif json_data:
730
+ response = requests.request(method, url, json=json_data, headers=headers, timeout=30)
731
+ else:
732
+ response = requests.request(method, url, data=data, headers=headers, timeout=30)
733
+ except requests.RequestException as e:
734
+ raise FactPulseValidationError(f"Network error: {e}")
735
+
736
+ if response.status_code >= 400:
737
+ try:
738
+ error_json = response.json()
739
+ error_msg = error_json.get("detail", error_json.get("errorMessage", str(error_json)))
740
+ except Exception:
741
+ error_msg = response.text or f"HTTP error {response.status_code}"
742
+ raise FactPulseValidationError(f"API error: {error_msg}")
743
+
744
+ return response
745
+
746
+ def get_processing_api(self) -> InvoiceProcessingApi:
747
+ """Return the invoice processing API."""
748
+ self.ensure_authenticated()
749
+ config = Configuration(host=f"{self.api_url}/api/facturation")
750
+ config.access_token = self._access_token
751
+ self._api_client = ApiClient(configuration=config)
752
+ return InvoiceProcessingApi(api_client=self._api_client)
753
+
754
+ def poll_task(self, task_id: str, timeout: Optional[int] = None, interval: Optional[int] = None) -> Dict[str, Any]:
755
+ """Poll a task until completion."""
756
+ timeout_ms = timeout or self.polling_timeout
757
+ interval_ms = interval or self.polling_interval
758
+
759
+ start_time = time.time() * 1000
760
+ current_interval = float(interval_ms)
761
+
762
+ logger.info("Starting polling for task %s (timeout: %dms)", task_id, timeout_ms)
763
+
764
+ while True:
765
+ elapsed = (time.time() * 1000) - start_time
766
+
767
+ if elapsed > timeout_ms:
768
+ raise FactPulsePollingTimeout(task_id, timeout_ms)
769
+
770
+ try:
771
+ logger.debug("Polling task %s (elapsed: %.0fms)...", task_id, elapsed)
772
+ api = self.get_processing_api()
773
+ status = api.get_task_status_api_v1_processing_tasks_task_id_status_get(task_id=task_id)
774
+ logger.debug("Status response received: %s", status)
775
+
776
+ status_value = status.status.value if hasattr(status.status, "value") else str(status.status)
777
+ logger.info("Task %s: status=%s (%.0fms)", task_id, status_value, elapsed)
778
+
779
+ if status_value == "SUCCESS":
780
+ logger.info("Task %s completed successfully", task_id)
781
+ if status.result:
782
+ if hasattr(status.result, "to_dict"):
783
+ return status.result.to_dict()
784
+ return dict(status.result)
785
+ return {}
786
+
787
+ if status_value == "FAILURE":
788
+ error_msg = "Unknown error"
789
+ errors = []
790
+ if status.result:
791
+ result = status.result.to_dict() if hasattr(status.result, "to_dict") else dict(status.result)
792
+ # AFNOR format: errorMessage, details
793
+ error_msg = result.get("errorMessage", error_msg)
794
+ for err in result.get("details", []):
795
+ errors.append(ValidationErrorDetail(
796
+ level=err.get("level", ""),
797
+ item=err.get("item", ""),
798
+ reason=err.get("reason", ""),
799
+ source=err.get("source"),
800
+ code=err.get("code"),
801
+ ))
802
+ raise FactPulseValidationError(f"Task {task_id} failed: {error_msg}", errors)
803
+
804
+ except (FactPulseValidationError, FactPulsePollingTimeout):
805
+ raise
806
+ except Exception as e:
807
+ error_str = str(e)
808
+ logger.warning("Error during polling: %s", error_str)
809
+
810
+ # Rate limit (429) - wait and retry with backoff
811
+ if "429" in error_str:
812
+ wait_time = min(current_interval * 2, 30000) # Max 30s
813
+ logger.warning("Rate limit (429), waiting %.1fs before retry...", wait_time / 1000)
814
+ time.sleep(wait_time / 1000)
815
+ current_interval = wait_time
816
+ continue
817
+
818
+ # Token expired (401) - re-authenticate
819
+ if "401" in error_str:
820
+ logger.warning("Token expired, re-authenticating...")
821
+ self.reset_auth()
822
+ continue
823
+
824
+ # Temporary server error (502, 503, 504) - retry with backoff
825
+ if any(code in error_str for code in ("502", "503", "504")):
826
+ wait_time = min(current_interval * 1.5, 15000)
827
+ logger.warning("Temporary server error, waiting %.1fs before retry...", wait_time / 1000)
828
+ time.sleep(wait_time / 1000)
829
+ current_interval = wait_time
830
+ continue
831
+
832
+ raise FactPulseValidationError(f"API error: {e}")
833
+
834
+ time.sleep(current_interval / 1000)
835
+ current_interval = min(current_interval * 1.5, 10000)
836
+
837
+ def generate_facturx(
838
+ self,
839
+ invoice_data: Union[Dict, str, Any],
840
+ pdf_source: Union[bytes, str, Path],
841
+ profile: str = "EN16931",
842
+ output_format: str = "pdf",
843
+ sync: bool = True,
844
+ timeout: Optional[int] = None,
845
+ ) -> bytes:
846
+ """Generate a Factur-X invoice.
847
+
848
+ Accepts invoice data in multiple forms:
849
+ - Dict: Python dictionary (recommended with helpers invoice_totals(), invoice_line(), etc.)
850
+ - str: Serialized JSON
851
+ - Pydantic model: SDK-generated model (will be converted via .to_dict())
852
+
853
+ Args:
854
+ invoice_data: Invoice data (dict, JSON string, or Pydantic model)
855
+ pdf_source: Path to source PDF, or PDF bytes
856
+ profile: Factur-X profile (MINIMUM, BASIC, EN16931, EXTENDED)
857
+ output_format: Output format (pdf, xml, both)
858
+ sync: If True, wait for task completion and return result
859
+ timeout: Polling timeout in ms
860
+
861
+ Returns:
862
+ bytes: Generated file content (PDF or XML)
863
+ """
864
+ # Convert data to JSON string (handles Decimal, datetime, etc.)
865
+ if isinstance(invoice_data, str):
866
+ json_data = invoice_data
867
+ elif isinstance(invoice_data, dict):
868
+ json_data = json_dumps_safe(invoice_data)
869
+ elif hasattr(invoice_data, "to_dict"):
870
+ # Pydantic model generated by SDK
871
+ json_data = json_dumps_safe(invoice_data.to_dict())
872
+ else:
873
+ raise FactPulseValidationError(f"Unsupported data type: {type(invoice_data)}")
874
+
875
+ # Prepare PDF
876
+ if isinstance(pdf_source, (str, Path)):
877
+ pdf_path = Path(pdf_source)
878
+ pdf_bytes = pdf_path.read_bytes()
879
+ pdf_filename = pdf_path.name
880
+ else:
881
+ pdf_bytes = pdf_source
882
+ pdf_filename = "source.pdf"
883
+
884
+ # Direct send via requests (bypass SDK Pydantic models)
885
+ for attempt in range(self.max_retries + 1):
886
+ self.ensure_authenticated()
887
+ try:
888
+ url = f"{self.api_url}/api/v1/processing/generate-invoice"
889
+ files = {
890
+ "invoice_data": (None, json_data, "application/json"),
891
+ "profile": (None, profile),
892
+ "output_format": (None, output_format),
893
+ "source_pdf": (pdf_filename, pdf_bytes, "application/pdf"),
894
+ }
895
+ headers = {"Authorization": f"Bearer {self._access_token}"}
896
+ response = requests.post(url, files=files, headers=headers, timeout=60)
897
+
898
+ if response.status_code == 401 and attempt < self.max_retries:
899
+ logger.warning("Error 401, resetting token (attempt %d/%d)", attempt + 1, self.max_retries + 1)
900
+ self.reset_auth()
901
+ continue
902
+
903
+ # Handle HTTP errors with response body extraction
904
+ if response.status_code >= 400:
905
+ error_body = None
906
+ try:
907
+ error_body = response.json()
908
+ except Exception:
909
+ error_body = {"detail": response.text or f"HTTP {response.status_code}"}
910
+
911
+ # Detailed error logging
912
+ logger.error("API error %d: %s", response.status_code, error_body)
913
+
914
+ # Extract error details in standardized format
915
+ errors = []
916
+ error_msg = f"HTTP error {response.status_code}"
917
+
918
+ if isinstance(error_body, dict):
919
+ # FastAPI/Pydantic format: {"detail": [{"loc": [...], "msg": "...", "type": "..."}]}
920
+ if "detail" in error_body:
921
+ detail = error_body["detail"]
922
+ if isinstance(detail, list):
923
+ # Pydantic validation error list
924
+ error_msg = "Validation error"
925
+ for err in detail:
926
+ if isinstance(err, dict):
927
+ loc = err.get("loc", [])
928
+ loc_str = " -> ".join(str(l) for l in loc) if loc else ""
929
+ errors.append(ValidationErrorDetail(
930
+ level="ERROR",
931
+ item=loc_str,
932
+ reason=err.get("msg", str(err)),
933
+ source="validation",
934
+ code=err.get("type"),
935
+ ))
936
+ elif isinstance(detail, str):
937
+ error_msg = detail
938
+ # AFNOR format: {"errorMessage": "...", "details": [...]}
939
+ elif "errorMessage" in error_body:
940
+ error_msg = error_body["errorMessage"]
941
+ for err in error_body.get("details", []):
942
+ errors.append(ValidationErrorDetail(
943
+ level=err.get("level", "ERROR"),
944
+ item=err.get("item", ""),
945
+ reason=err.get("reason", ""),
946
+ source=err.get("source"),
947
+ code=err.get("code"),
948
+ ))
949
+
950
+ # For 422 errors (validation), don't retry
951
+ if response.status_code == 422:
952
+ raise FactPulseValidationError(error_msg, errors)
953
+
954
+ # For other client errors (4xx), don't retry either
955
+ if 400 <= response.status_code < 500:
956
+ raise FactPulseValidationError(error_msg, errors)
957
+
958
+ # For server errors (5xx), retry if possible
959
+ if attempt < self.max_retries:
960
+ logger.warning("Server error %d (attempt %d/%d)", response.status_code, attempt + 1, self.max_retries + 1)
961
+ continue
962
+ raise FactPulseValidationError(error_msg, errors)
963
+
964
+ result = response.json()
965
+ task_id = result.get("task_id")
966
+
967
+ if not task_id:
968
+ raise FactPulseValidationError("No task ID in response")
969
+
970
+ if not sync:
971
+ return task_id.encode()
972
+
973
+ poll_result = self.poll_task(task_id, timeout)
974
+
975
+ if poll_result.get("status") == "ERROR":
976
+ # AFNOR format: errorMessage, details
977
+ error_msg = poll_result.get("errorMessage", "Validation error")
978
+ errors = [
979
+ ValidationErrorDetail(
980
+ level=e.get("level", ""),
981
+ item=e.get("item", ""),
982
+ reason=e.get("reason", ""),
983
+ source=e.get("source"),
984
+ code=e.get("code"),
985
+ )
986
+ for e in poll_result.get("details", [])
987
+ ]
988
+ raise FactPulseValidationError(error_msg, errors)
989
+
990
+ if "content_b64" in poll_result:
991
+ return base64.b64decode(poll_result["content_b64"])
992
+
993
+ raise FactPulseValidationError("Result does not contain content")
994
+
995
+ except requests.RequestException as e:
996
+ # Network errors (connection, timeout, etc.) - no HTTP error
997
+ if attempt < self.max_retries:
998
+ logger.warning("Network error (attempt %d/%d): %s", attempt + 1, self.max_retries + 1, e)
999
+ continue
1000
+ raise FactPulseValidationError(f"Network error: {e}")
1001
+
1002
+ raise FactPulseValidationError("Failed after all attempts")
1003
+
1004
+
1005
+ @staticmethod
1006
+ def format_amount(value) -> str:
1007
+ """Format an amount for the FactPulse API."""
1008
+ if value is None:
1009
+ return "0.00"
1010
+ if isinstance(value, Decimal):
1011
+ return f"{value:.2f}"
1012
+ if isinstance(value, (int, float)):
1013
+ return f"{value:.2f}"
1014
+ if isinstance(value, str):
1015
+ return value
1016
+ return "0.00"
1017
+
1018
+ # =========================================================================
1019
+ # AFNOR PDP/PA - Flow Service
1020
+ # =========================================================================
1021
+ #
1022
+ # ARCHITECTURE SET IN STONE - DO NOT MODIFY WITHOUT UNDERSTANDING
1023
+ #
1024
+ # The AFNOR proxy is 100% TRANSPARENT. It has the same OpenAPI as AFNOR.
1025
+ # The SDK must ALWAYS:
1026
+ # 1. Obtain AFNOR credentials (stored mode: via /credentials, zero-trust mode: provided)
1027
+ # 2. Perform AFNOR OAuth itself
1028
+ # 3. Call endpoints with AFNOR token + X-PDP-Base-URL header
1029
+ #
1030
+ # The FactPulse JWT token is NEVER used to call the PDP!
1031
+ # It's only used to retrieve credentials in stored mode.
1032
+ # =========================================================================
1033
+
1034
+ def _get_afnor_credentials(self) -> "AFNORCredentials":
1035
+ """Obtain AFNOR credentials (stored or zero-trust mode).
1036
+
1037
+ **Zero-trust mode**: Returns afnor_credentials provided to constructor.
1038
+ **Stored mode**: Retrieves credentials via GET /api/v1/afnor/credentials.
1039
+
1040
+ Returns:
1041
+ AFNORCredentials with flow_service_url, token_url, client_id, client_secret
1042
+
1043
+ Raises:
1044
+ FactPulseAuthError: If no credentials available
1045
+ FactPulseServiceUnavailableError: If server is unavailable
1046
+ """
1047
+ from .exceptions import FactPulseServiceUnavailableError
1048
+
1049
+ # Zero-trust mode: credentials provided to constructor
1050
+ if self.afnor_credentials:
1051
+ logger.info("Zero-trust mode: using provided AFNORCredentials")
1052
+ return self.afnor_credentials
1053
+
1054
+ # Stored mode: retrieve credentials via API
1055
+ logger.info("Stored mode: retrieving credentials via /api/v1/afnor/credentials")
1056
+
1057
+ self.ensure_authenticated() # Ensure we have a FactPulse JWT token
1058
+
1059
+ url = f"{self.api_url}/api/v1/afnor/credentials"
1060
+ headers = {"Authorization": f"Bearer {self._access_token}"}
1061
+
1062
+ try:
1063
+ response = requests.get(url, headers=headers, timeout=10)
1064
+ except requests.RequestException as e:
1065
+ raise FactPulseServiceUnavailableError("FactPulse AFNOR credentials", e)
1066
+
1067
+ if response.status_code == 400:
1068
+ error_json = response.json()
1069
+ error_detail = error_json.get("detail", {})
1070
+ if isinstance(error_detail, dict) and error_detail.get("error") == "NO_CLIENT_UID":
1071
+ raise FactPulseAuthError(
1072
+ "No client_uid in JWT. "
1073
+ "To use AFNOR endpoints, either:\n"
1074
+ "1. Generate a token with a client_uid (stored mode)\n"
1075
+ "2. Provide AFNORCredentials to the client constructor (zero-trust mode)"
1076
+ )
1077
+ raise FactPulseAuthError(f"AFNOR credentials error: {error_detail}")
1078
+
1079
+ if response.status_code != 200:
1080
+ try:
1081
+ error_json = response.json()
1082
+ error_msg = error_json.get("detail", str(error_json))
1083
+ except Exception:
1084
+ error_msg = response.text or f"HTTP {response.status_code}"
1085
+ raise FactPulseAuthError(f"Failed to retrieve AFNOR credentials: {error_msg}")
1086
+
1087
+ creds = response.json()
1088
+ logger.info(f"AFNOR credentials retrieved for PDP: {creds.get('flow_service_url')}")
1089
+
1090
+ # Create temporary AFNORCredentials
1091
+ return AFNORCredentials(
1092
+ flow_service_url=creds["flow_service_url"],
1093
+ token_url=creds["token_url"],
1094
+ client_id=creds["client_id"],
1095
+ client_secret=creds["client_secret"],
1096
+ )
1097
+
1098
+ def _get_afnor_token_and_url(self) -> Tuple[str, str]:
1099
+ """Obtain AFNOR OAuth2 token and PDP URL.
1100
+
1101
+ This method:
1102
+ 1. Retrieves AFNOR credentials (stored or zero-trust mode)
1103
+ 2. Performs AFNOR OAuth to obtain a token
1104
+ 3. Returns the token and PDP URL
1105
+
1106
+ Returns:
1107
+ Tuple (afnor_token, pdp_base_url)
1108
+
1109
+ Raises:
1110
+ FactPulseAuthError: If authentication fails
1111
+ FactPulseServiceUnavailableError: If service is unavailable
1112
+ """
1113
+ from .exceptions import FactPulseServiceUnavailableError
1114
+
1115
+ # Step 1: Get AFNOR credentials
1116
+ credentials = self._get_afnor_credentials()
1117
+
1118
+ # Step 2: Perform AFNOR OAuth
1119
+ logger.info(f"AFNOR OAuth to: {credentials.token_url}")
1120
+
1121
+ url = f"{self.api_url}/api/v1/afnor/oauth/token"
1122
+ oauth_data = {
1123
+ "grant_type": "client_credentials",
1124
+ "client_id": credentials.client_id,
1125
+ "client_secret": credentials.client_secret,
1126
+ }
1127
+ headers = {
1128
+ "X-PDP-Token-URL": credentials.token_url,
1129
+ }
1130
+
1131
+ try:
1132
+ response = requests.post(url, data=oauth_data, headers=headers, timeout=10)
1133
+ except requests.RequestException as e:
1134
+ raise FactPulseServiceUnavailableError("AFNOR OAuth", e)
1135
+
1136
+ if response.status_code != 200:
1137
+ try:
1138
+ error_json = response.json()
1139
+ error_msg = error_json.get("detail", error_json.get("error", str(error_json)))
1140
+ except Exception:
1141
+ error_msg = response.text or f"HTTP {response.status_code}"
1142
+ raise FactPulseAuthError(f"AFNOR OAuth2 failed: {error_msg}")
1143
+
1144
+ token_data = response.json()
1145
+ afnor_token = token_data.get("access_token")
1146
+
1147
+ if not afnor_token:
1148
+ raise FactPulseAuthError("Invalid AFNOR OAuth2 response: missing access_token")
1149
+
1150
+ logger.info("AFNOR OAuth2 token obtained successfully")
1151
+ return afnor_token, credentials.flow_service_url
1152
+
1153
+ def _make_afnor_request(
1154
+ self,
1155
+ method: str,
1156
+ endpoint: str,
1157
+ json_data: Optional[Dict] = None,
1158
+ files: Optional[Dict] = None,
1159
+ params: Optional[Dict] = None,
1160
+ ) -> requests.Response:
1161
+ """Perform a request to the AFNOR API with auth and error handling.
1162
+
1163
+ ================================================================================
1164
+ ARCHITECTURE SET IN STONE
1165
+ ================================================================================
1166
+
1167
+ This method:
1168
+ 1. Retrieves AFNOR credentials (stored mode: API, zero-trust mode: provided)
1169
+ 2. Performs AFNOR OAuth to obtain an AFNOR token
1170
+ 3. Calls the endpoint with:
1171
+ - Authorization: Bearer {afnor_token} <- AFNOR TOKEN, NOT FACTPULSE JWT!
1172
+ - X-PDP-Base-URL: {pdp_url} <- For proxy to route to correct PDP
1173
+
1174
+ The FactPulse JWT token is NEVER used to call the PDP.
1175
+ It's only used to retrieve credentials in stored mode.
1176
+
1177
+ ================================================================================
1178
+
1179
+ Args:
1180
+ method: HTTP method (GET, POST, etc.)
1181
+ endpoint: Relative endpoint (e.g., /flow/v1/flows)
1182
+ json_data: JSON data (optional)
1183
+ files: Multipart files (optional)
1184
+ params: Query params (optional)
1185
+
1186
+ Returns:
1187
+ API response
1188
+
1189
+ Raises:
1190
+ FactPulseAuthError: If 401 or missing credentials
1191
+ FactPulseNotFoundError: If 404
1192
+ FactPulseServiceUnavailableError: If 503
1193
+ FactPulseValidationError: If 400/422
1194
+ FactPulseAPIError: Other errors
1195
+ """
1196
+ from .exceptions import (
1197
+ parse_api_error,
1198
+ FactPulseServiceUnavailableError,
1199
+ )
1200
+
1201
+ # Get AFNOR token and PDP URL
1202
+ # (stored mode: retrieves credentials via API, zero-trust mode: uses provided credentials)
1203
+ afnor_token, pdp_base_url = self._get_afnor_token_and_url()
1204
+
1205
+ url = f"{self.api_url}/api/v1/afnor{endpoint}"
1206
+
1207
+ # ALWAYS use AFNOR token + X-PDP-Base-URL header
1208
+ # The FactPulse JWT token is NEVER used to call the PDP!
1209
+ headers = {
1210
+ "Authorization": f"Bearer {afnor_token}",
1211
+ "X-PDP-Base-URL": pdp_base_url,
1212
+ }
1213
+
1214
+ try:
1215
+ if files:
1216
+ response = requests.request(
1217
+ method, url, files=files, headers=headers, params=params, timeout=60
1218
+ )
1219
+ else:
1220
+ response = requests.request(
1221
+ method, url, json=json_data, headers=headers, params=params, timeout=30
1222
+ )
1223
+ except requests.RequestException as e:
1224
+ raise FactPulseServiceUnavailableError("AFNOR PDP", e)
1225
+
1226
+ if response.status_code >= 400:
1227
+ try:
1228
+ error_json = response.json()
1229
+ except Exception:
1230
+ error_json = {"errorMessage": response.text or f"HTTP error {response.status_code}"}
1231
+ raise parse_api_error(error_json, response.status_code)
1232
+
1233
+ return response
1234
+
1235
+ def submit_invoice_afnor(
1236
+ self,
1237
+ flow_name: str,
1238
+ pdf_path: Optional[Union[str, Path]] = None,
1239
+ pdf_bytes: Optional[bytes] = None,
1240
+ pdf_filename: str = "invoice.pdf",
1241
+ flow_syntax: str = "CII",
1242
+ flow_profile: str = "EN16931",
1243
+ tracking_id: Optional[str] = None,
1244
+ sha256: Optional[str] = None,
1245
+ ) -> Dict[str, Any]:
1246
+ """Submit a Factur-X invoice to a PDP via the AFNOR API.
1247
+
1248
+ Authentication uses either the client_uid from JWT (stored mode),
1249
+ or afnor_credentials provided to constructor (zero-trust mode).
1250
+
1251
+ Args:
1252
+ flow_name: Flow name (e.g., "Invoice INV-2025-001")
1253
+ pdf_path: Path to PDF/A-3 file (exclusive with pdf_bytes)
1254
+ pdf_bytes: PDF content as bytes (exclusive with pdf_path)
1255
+ pdf_filename: Filename for bytes (default: "invoice.pdf")
1256
+ flow_syntax: Flow syntax (CII or UBL)
1257
+ flow_profile: Factur-X profile (MINIMUM, BASIC, EN16931, EXTENDED)
1258
+ tracking_id: Business tracking identifier (optional)
1259
+ sha256: SHA-256 hash of file (calculated automatically if absent)
1260
+
1261
+ Returns:
1262
+ Dict with flowId, trackingId, status, sha256, etc.
1263
+
1264
+ Raises:
1265
+ FactPulseValidationError: If PDF is not valid
1266
+ FactPulseServiceUnavailableError: If PDP is unavailable
1267
+ ValueError: If neither pdf_path nor pdf_bytes is provided
1268
+
1269
+ Example:
1270
+ >>> # With a file path
1271
+ >>> result = client.submit_invoice_afnor(
1272
+ ... flow_name="Invoice INV-2025-001",
1273
+ ... pdf_path="invoice.pdf",
1274
+ ... tracking_id="INV-2025-001",
1275
+ ... )
1276
+ >>> print(result["flowId"])
1277
+
1278
+ >>> # With bytes (e.g., after Factur-X generation)
1279
+ >>> result = client.submit_invoice_afnor(
1280
+ ... flow_name="Invoice INV-2025-001",
1281
+ ... pdf_bytes=pdf_content,
1282
+ ... pdf_filename="INV-2025-001.pdf",
1283
+ ... tracking_id="INV-2025-001",
1284
+ ... )
1285
+ """
1286
+ import hashlib
1287
+
1288
+ # Load PDF from path if provided
1289
+ filename = pdf_filename
1290
+ if pdf_path:
1291
+ pdf_path = Path(pdf_path)
1292
+ pdf_bytes = pdf_path.read_bytes()
1293
+ filename = pdf_path.name
1294
+
1295
+ if not pdf_bytes:
1296
+ raise ValueError("pdf_path or pdf_bytes required")
1297
+
1298
+ # Calculate SHA-256 if not provided
1299
+ if not sha256:
1300
+ sha256 = hashlib.sha256(pdf_bytes).hexdigest()
1301
+
1302
+ # Prepare flowInfo
1303
+ flow_info = {
1304
+ "name": flow_name,
1305
+ "flowSyntax": flow_syntax,
1306
+ "flowProfile": flow_profile,
1307
+ "sha256": sha256,
1308
+ }
1309
+ if tracking_id:
1310
+ flow_info["trackingId"] = tracking_id
1311
+
1312
+ files = {
1313
+ "file": (filename, pdf_bytes, "application/pdf"),
1314
+ "flowInfo": (None, json_dumps_safe(flow_info), "application/json"),
1315
+ }
1316
+
1317
+ response = self._make_afnor_request("POST", "/flow/v1/flows", files=files)
1318
+ return response.json()
1319
+
1320
+
1321
+ def search_flows_afnor(
1322
+ self,
1323
+ tracking_id: Optional[str] = None,
1324
+ status: Optional[str] = None,
1325
+ offset: int = 0,
1326
+ limit: int = 25,
1327
+ ) -> Dict[str, Any]:
1328
+ """Search AFNOR invoice flows.
1329
+
1330
+ Args:
1331
+ tracking_id: Filter by trackingId
1332
+ status: Filter by status (submitted, processing, delivered, etc.)
1333
+ offset: Start index (pagination)
1334
+ limit: Max number of results
1335
+
1336
+ Returns:
1337
+ Dict with flows (list), total, offset, limit
1338
+
1339
+ Example:
1340
+ >>> results = client.search_flows_afnor(tracking_id="INV-2025-001")
1341
+ >>> for flow in results["flows"]:
1342
+ ... print(flow["flowId"], flow["status"])
1343
+ """
1344
+ search_body = {
1345
+ "offset": offset,
1346
+ "limit": limit,
1347
+ "where": {},
1348
+ }
1349
+ if tracking_id:
1350
+ search_body["where"]["trackingId"] = tracking_id
1351
+ if status:
1352
+ search_body["where"]["status"] = status
1353
+
1354
+ response = self._make_afnor_request("POST", "/flow/v1/flows/search", json_data=search_body)
1355
+ return response.json()
1356
+
1357
+
1358
+ def download_flow_afnor(self, flow_id: str) -> bytes:
1359
+ """Download the PDF file of an AFNOR flow.
1360
+
1361
+ Args:
1362
+ flow_id: Flow identifier (UUID)
1363
+
1364
+ Returns:
1365
+ PDF file content
1366
+
1367
+ Raises:
1368
+ FactPulseNotFoundError: If flow doesn't exist
1369
+
1370
+ Example:
1371
+ >>> pdf_bytes = client.download_flow_afnor("550e8400-e29b-41d4-a716-446655440000")
1372
+ >>> with open("invoice.pdf", "wb") as f:
1373
+ ... f.write(pdf_bytes)
1374
+ """
1375
+ response = self._make_afnor_request("GET", f"/flow/v1/flows/{flow_id}")
1376
+ return response.content
1377
+
1378
+
1379
+ def get_incoming_invoice_afnor(
1380
+ self,
1381
+ flow_id: str,
1382
+ include_document: bool = False,
1383
+ ) -> Dict[str, Any]:
1384
+ """Retrieve JSON metadata of an incoming flow (supplier invoice).
1385
+
1386
+ Downloads an incoming flow from the AFNOR PDP and extracts invoice
1387
+ metadata to a unified JSON format. Supports Factur-X, CII and UBL formats.
1388
+
1389
+ Note: This endpoint uses FactPulse JWT authentication (not AFNOR OAuth).
1390
+ The FactPulse server handles calling the PDP with stored credentials.
1391
+
1392
+ Args:
1393
+ flow_id: Flow identifier (UUID)
1394
+ include_document: If True, include original document encoded in base64
1395
+
1396
+ Returns:
1397
+ Dict with invoice metadata:
1398
+ - flow_id: Flow identifier
1399
+ - source_format: Detected format (Factur-X, CII, UBL)
1400
+ - supplier_reference: Supplier invoice number
1401
+ - document_type: Type code (380=invoice, 381=credit note, etc.)
1402
+ - supplier: Dict with name, siret, vat_number
1403
+ - billing_site_name: Recipient name
1404
+ - billing_site_siret: Recipient SIRET
1405
+ - document_date: Invoice date (YYYY-MM-DD)
1406
+ - due_date: Due date (YYYY-MM-DD)
1407
+ - currency: Currency code (EUR, USD, etc.)
1408
+ - total_excl_tax: Total excl. tax
1409
+ - total_vat: VAT amount
1410
+ - total_incl_tax: Total incl. tax
1411
+ - document_base64: (if include_document=True) Encoded document
1412
+ - document_content_type: (if include_document=True) MIME type
1413
+ - document_filename: (if include_document=True) Filename
1414
+
1415
+ Raises:
1416
+ FactPulseNotFoundError: If flow doesn't exist
1417
+ FactPulseValidationError: If format is not supported
1418
+
1419
+ Example:
1420
+ >>> # Retrieve incoming invoice metadata
1421
+ >>> invoice = client.get_incoming_invoice_afnor("550e8400-e29b-41d4-a716-446655440000")
1422
+ >>> print(f"Supplier: {invoice['supplier']['name']}")
1423
+ >>> print(f"Total incl. tax: {invoice['total_incl_tax']} {invoice['currency']}")
1424
+
1425
+ >>> # With original document
1426
+ >>> invoice = client.get_incoming_invoice_afnor(flow_id, include_document=True)
1427
+ >>> if invoice.get('document_base64'):
1428
+ ... import base64
1429
+ ... pdf_bytes = base64.b64decode(invoice['document_base64'])
1430
+ ... with open(invoice['document_filename'], 'wb') as f:
1431
+ ... f.write(pdf_bytes)
1432
+ """
1433
+ from .exceptions import FactPulseNotFoundError, FactPulseServiceUnavailableError, parse_api_error
1434
+
1435
+ self.ensure_authenticated()
1436
+
1437
+ url = f"{self.api_url}/api/v1/afnor/incoming-flows/{flow_id}"
1438
+ params = {}
1439
+ if include_document:
1440
+ params["include_document"] = "true"
1441
+
1442
+ headers = {"Authorization": f"Bearer {self._access_token}"}
1443
+
1444
+ try:
1445
+ response = requests.get(url, headers=headers, params=params if params else None, timeout=60)
1446
+ except requests.RequestException as e:
1447
+ raise FactPulseServiceUnavailableError("FactPulse AFNOR incoming flows", e)
1448
+
1449
+ if response.status_code >= 400:
1450
+ try:
1451
+ error_json = response.json()
1452
+ except Exception:
1453
+ error_json = {"detail": response.text or f"HTTP error {response.status_code}"}
1454
+ raise parse_api_error(error_json, response.status_code)
1455
+
1456
+ return response.json()
1457
+
1458
+
1459
+ def healthcheck_afnor(self) -> Dict[str, Any]:
1460
+ """Check AFNOR Flow Service availability.
1461
+
1462
+ Returns:
1463
+ Dict with status and service
1464
+
1465
+ Example:
1466
+ >>> status = client.healthcheck_afnor()
1467
+ >>> print(status["status"]) # "ok"
1468
+ """
1469
+ response = self._make_afnor_request("GET", "/flow/v1/healthcheck")
1470
+ return response.json()
1471
+
1472
+ # =========================================================================
1473
+ # Chorus Pro
1474
+ # =========================================================================
1475
+
1476
+ def _make_chorus_request(
1477
+ self,
1478
+ method: str,
1479
+ endpoint: str,
1480
+ json_data: Optional[Dict] = None,
1481
+ params: Optional[Dict] = None,
1482
+ ) -> requests.Response:
1483
+ """Perform a request to the Chorus Pro API with auth and error handling.
1484
+
1485
+ Args:
1486
+ method: HTTP method (GET, POST, etc.)
1487
+ endpoint: Relative endpoint (e.g., /structures/rechercher)
1488
+ json_data: JSON data (optional)
1489
+ params: Query params (optional)
1490
+
1491
+ Returns:
1492
+ API response
1493
+ """
1494
+ from .exceptions import (
1495
+ parse_api_error,
1496
+ FactPulseServiceUnavailableError,
1497
+ )
1498
+
1499
+ self.ensure_authenticated()
1500
+ url = f"{self.api_url}/api/v1/chorus-pro{endpoint}"
1501
+
1502
+ headers = {"Authorization": f"Bearer {self._access_token}"}
1503
+
1504
+ # Add credentials to body if zero-trust mode
1505
+ if json_data is None:
1506
+ json_data = {}
1507
+ if self.chorus_credentials:
1508
+ json_data["credentials"] = self.chorus_credentials.to_dict()
1509
+
1510
+ try:
1511
+ response = requests.request(
1512
+ method, url, json=json_data, headers=headers, params=params, timeout=30
1513
+ )
1514
+ except requests.RequestException as e:
1515
+ raise FactPulseServiceUnavailableError("Chorus Pro", e)
1516
+
1517
+ if response.status_code >= 400:
1518
+ try:
1519
+ error_json = response.json()
1520
+ except Exception:
1521
+ error_json = {"errorMessage": response.text or f"HTTP error {response.status_code}"}
1522
+ raise parse_api_error(error_json, response.status_code)
1523
+
1524
+ return response
1525
+
1526
+ def search_structure_chorus(
1527
+ self,
1528
+ structure_identifier: Optional[str] = None,
1529
+ company_name: Optional[str] = None,
1530
+ identifier_type: str = "SIRET",
1531
+ restrict_to_private: bool = True,
1532
+ ) -> Dict[str, Any]:
1533
+ """Search structures on Chorus Pro.
1534
+
1535
+ Args:
1536
+ structure_identifier: Structure SIRET or SIREN
1537
+ company_name: Company name (partial search)
1538
+ identifier_type: Identifier type (SIRET, SIREN, etc.)
1539
+ restrict_to_private: If True, limit to private structures
1540
+
1541
+ Returns:
1542
+ Dict with liste_structures, total, code_retour, libelle
1543
+
1544
+ Example:
1545
+ >>> result = client.search_structure_chorus(structure_identifier="12345678901234")
1546
+ >>> for struct in result["liste_structures"]:
1547
+ ... print(struct["id_structure_cpp"], struct["designation_structure"])
1548
+ """
1549
+ body = {
1550
+ "restreindre_structures_privees": restrict_to_private,
1551
+ }
1552
+ if structure_identifier:
1553
+ body["identifiant_structure"] = structure_identifier
1554
+ if company_name:
1555
+ body["raison_sociale_structure"] = company_name
1556
+ if identifier_type:
1557
+ body["type_identifiant_structure"] = identifier_type
1558
+
1559
+ response = self._make_chorus_request("POST", "/structures/rechercher", json_data=body)
1560
+ return response.json()
1561
+
1562
+
1563
+ def get_structure_details_chorus(self, structure_cpp_id: int) -> Dict[str, Any]:
1564
+ """Get details of a Chorus Pro structure.
1565
+
1566
+ Returns mandatory parameters for submitting an invoice:
1567
+ - code_service_doit_etre_renseigne
1568
+ - numero_ej_doit_etre_renseigne
1569
+
1570
+ Args:
1571
+ structure_cpp_id: Chorus Pro structure ID
1572
+
1573
+ Returns:
1574
+ Dict with structure details and parameters
1575
+
1576
+ Example:
1577
+ >>> details = client.get_structure_details_chorus(12345)
1578
+ >>> if details["parametres"]["code_service_doit_etre_renseigne"]:
1579
+ ... print("Service code required")
1580
+ """
1581
+ body = {"id_structure_cpp": structure_cpp_id}
1582
+ response = self._make_chorus_request("POST", "/structures/consulter", json_data=body)
1583
+ return response.json()
1584
+
1585
+ def get_chorus_id_from_siret(
1586
+ self,
1587
+ siret: str,
1588
+ identifier_type: str = "SIRET",
1589
+ ) -> Dict[str, Any]:
1590
+ """Get Chorus Pro ID from SIRET.
1591
+
1592
+ Convenient shortcut to get id_structure_cpp before submitting an invoice.
1593
+
1594
+ Args:
1595
+ siret: SIRET or SIREN number
1596
+ identifier_type: Identifier type (SIRET or SIREN)
1597
+
1598
+ Returns:
1599
+ Dict with id_structure_cpp, designation_structure, message
1600
+
1601
+ Example:
1602
+ >>> result = client.get_chorus_id_from_siret("12345678901234")
1603
+ >>> id_cpp = result["id_structure_cpp"]
1604
+ >>> if id_cpp > 0:
1605
+ ... print(f"Structure found: {result['designation_structure']}")
1606
+ """
1607
+ body = {
1608
+ "siret": siret,
1609
+ "type_identifiant": identifier_type,
1610
+ }
1611
+ response = self._make_chorus_request("POST", "/structures/obtenir-id-depuis-siret", json_data=body)
1612
+ return response.json()
1613
+
1614
+ def list_structure_services_chorus(self, structure_cpp_id: int) -> Dict[str, Any]:
1615
+ """List services of a Chorus Pro structure.
1616
+
1617
+ Args:
1618
+ structure_cpp_id: Chorus Pro structure ID
1619
+
1620
+ Returns:
1621
+ Dict with liste_services, total, code_retour, libelle
1622
+
1623
+ Example:
1624
+ >>> services = client.list_structure_services_chorus(12345)
1625
+ >>> for svc in services["liste_services"]:
1626
+ ... if svc["est_actif"]:
1627
+ ... print(svc["code_service"], svc["libelle_service"])
1628
+ """
1629
+ response = self._make_chorus_request("GET", f"/structures/{structure_cpp_id}/services")
1630
+ return response.json()
1631
+
1632
+ def submit_invoice_chorus(
1633
+ self,
1634
+ invoice_number: str,
1635
+ invoice_date: str,
1636
+ due_date: str,
1637
+ structure_cpp_id: int,
1638
+ total_excl_tax: str,
1639
+ total_vat: str,
1640
+ total_incl_tax: str,
1641
+ main_attachment_id: Optional[int] = None,
1642
+ main_attachment_name: str = "Invoice",
1643
+ service_code: Optional[str] = None,
1644
+ commitment_number: Optional[str] = None,
1645
+ purchase_order_number: Optional[str] = None,
1646
+ contract_number: Optional[str] = None,
1647
+ comment: Optional[str] = None,
1648
+ ) -> Dict[str, Any]:
1649
+ """Submit an invoice to Chorus Pro.
1650
+
1651
+ **Complete workflow**:
1652
+ 1. Get id_structure_cpp via search_structure_chorus()
1653
+ 2. Check mandatory parameters via get_structure_details_chorus()
1654
+ 3. Upload PDF via /transverses/ajouter-fichier API
1655
+ 4. Submit invoice with this method
1656
+
1657
+ Args:
1658
+ invoice_number: Invoice number
1659
+ invoice_date: Invoice date (YYYY-MM-DD)
1660
+ due_date: Due date (YYYY-MM-DD)
1661
+ structure_cpp_id: Chorus Pro recipient ID
1662
+ total_excl_tax: Total excl. tax (e.g., "1000.00")
1663
+ total_vat: VAT amount (e.g., "200.00")
1664
+ total_incl_tax: Total incl. tax (e.g., "1200.00")
1665
+ main_attachment_id: Attachment ID (optional)
1666
+ main_attachment_name: Attachment name (default: "Invoice")
1667
+ service_code: Service code (if required by structure)
1668
+ commitment_number: Commitment number (if required)
1669
+ purchase_order_number: Purchase order number
1670
+ contract_number: Contract number
1671
+ comment: Free comment
1672
+
1673
+ Returns:
1674
+ Dict with identifiant_facture_cpp, numero_flux_depot, code_retour, libelle
1675
+
1676
+ Example:
1677
+ >>> result = client.submit_invoice_chorus(
1678
+ ... invoice_number="INV-2025-001",
1679
+ ... invoice_date="2025-01-15",
1680
+ ... due_date="2025-02-15",
1681
+ ... structure_cpp_id=12345,
1682
+ ... total_excl_tax="1000.00",
1683
+ ... total_vat="200.00",
1684
+ ... total_incl_tax="1200.00",
1685
+ ... )
1686
+ >>> print(f"Invoice submitted: {result['identifiant_facture_cpp']}")
1687
+ """
1688
+ body = {
1689
+ "numero_facture": invoice_number,
1690
+ "date_facture": invoice_date,
1691
+ "date_echeance_paiement": due_date,
1692
+ "id_structure_cpp": structure_cpp_id,
1693
+ "montant_ht_total": total_excl_tax,
1694
+ "montant_tva": total_vat,
1695
+ "montant_ttc_total": total_incl_tax,
1696
+ }
1697
+ if main_attachment_id:
1698
+ body["piece_jointe_principale_id"] = main_attachment_id
1699
+ body["piece_jointe_principale_designation"] = main_attachment_name
1700
+ if service_code:
1701
+ body["code_service"] = service_code
1702
+ if commitment_number:
1703
+ body["numero_engagement"] = commitment_number
1704
+ if purchase_order_number:
1705
+ body["numero_bon_commande"] = purchase_order_number
1706
+ if contract_number:
1707
+ body["numero_marche"] = contract_number
1708
+ if comment:
1709
+ body["commentaire"] = comment
1710
+
1711
+ response = self._make_chorus_request("POST", "/factures/soumettre", json_data=body)
1712
+ return response.json()
1713
+
1714
+ def get_invoice_status_chorus(self, invoice_cpp_id: int) -> Dict[str, Any]:
1715
+ """Get status of a Chorus Pro invoice.
1716
+
1717
+ Args:
1718
+ invoice_cpp_id: Chorus Pro invoice ID
1719
+
1720
+ Returns:
1721
+ Dict with statut_courant, numero_facture, date_facture, montant_ttc_total, etc.
1722
+
1723
+ Example:
1724
+ >>> status = client.get_invoice_status_chorus(12345)
1725
+ >>> print(f"Status: {status['statut_courant']['code']}")
1726
+ """
1727
+ body = {"identifiant_facture_cpp": invoice_cpp_id}
1728
+ response = self._make_chorus_request("POST", "/factures/consulter", json_data=body)
1729
+ return response.json()
1730
+
1731
+ # ==================== AFNOR Directory ====================
1732
+
1733
+ def search_siret_afnor(self, siret: str) -> Dict[str, Any]:
1734
+ """Search a company by SIRET in the AFNOR directory.
1735
+
1736
+ Args:
1737
+ siret: SIRET number (14 digits)
1738
+
1739
+ Returns:
1740
+ Dict with company info: company_name, address, etc.
1741
+
1742
+ Example:
1743
+ >>> result = client.search_siret_afnor("12345678901234")
1744
+ >>> print(f"Company: {result['raison_sociale']}")
1745
+ """
1746
+ response = self._make_afnor_request("GET", f"/directory/siret/{siret}")
1747
+ return response.json()
1748
+
1749
+
1750
+ def search_siren_afnor(self, siren: str) -> Dict[str, Any]:
1751
+ """Search a company by SIREN in the AFNOR directory.
1752
+
1753
+ Args:
1754
+ siren: SIREN number (9 digits)
1755
+
1756
+ Returns:
1757
+ Dict with company info and list of establishments
1758
+
1759
+ Example:
1760
+ >>> result = client.search_siren_afnor("123456789")
1761
+ >>> for estab in result.get('etablissements', []):
1762
+ ... print(f"SIRET: {estab['siret']}")
1763
+ """
1764
+ response = self._make_afnor_request("GET", f"/directory/siren/{siren}")
1765
+ return response.json()
1766
+
1767
+
1768
+ def list_routing_codes_afnor(self, siren: str) -> List[Dict[str, Any]]:
1769
+ """List available routing codes for a SIREN.
1770
+
1771
+ Args:
1772
+ siren: SIREN number (9 digits)
1773
+
1774
+ Returns:
1775
+ List of routing codes with their parameters
1776
+
1777
+ Example:
1778
+ >>> codes = client.list_routing_codes_afnor("123456789")
1779
+ >>> for code in codes:
1780
+ ... print(f"Code: {code['code_routage']}")
1781
+ """
1782
+ response = self._make_afnor_request("GET", f"/directory/siren/{siren}/routing-codes")
1783
+ return response.json()
1784
+
1785
+
1786
+ # ==================== Validation ====================
1787
+
1788
+ def validate_facturx_pdf(
1789
+ self,
1790
+ pdf_path: Optional[str] = None,
1791
+ pdf_bytes: Optional[bytes] = None,
1792
+ profile: Optional[str] = None,
1793
+ use_verapdf: bool = False,
1794
+ ) -> Dict[str, Any]:
1795
+ """Validate a Factur-X PDF.
1796
+
1797
+ Args:
1798
+ pdf_path: Path to PDF file (exclusive with pdf_bytes)
1799
+ pdf_bytes: PDF content as bytes (exclusive with pdf_path)
1800
+ profile: Expected Factur-X profile (MINIMUM, BASIC, EN16931, EXTENDED).
1801
+ If None, profile is auto-detected from embedded XML.
1802
+ use_verapdf: Enable strict PDF/A validation with VeraPDF (default: False).
1803
+ - False: Fast metadata validation (~100ms)
1804
+ - True: Strict ISO 19005 validation with 146+ rules (2-10s, recommended in production)
1805
+
1806
+ Returns:
1807
+ Dict with:
1808
+ - is_compliant (bool): True if PDF is compliant
1809
+ - xml_present (bool): True if Factur-X XML is embedded
1810
+ - xml_compliant (bool): True if XML is valid according to Schematron
1811
+ - detected_profile (str): Detected profile (MINIMUM, BASIC, EN16931, EXTENDED)
1812
+ - xml_errors (list): XML validation errors
1813
+ - pdfa_compliant (bool): True if PDF/A compliant
1814
+ - pdfa_version (str): Detected PDF/A version (e.g., "PDF/A-3B")
1815
+ - pdfa_validation_method (str): "metadata" or "verapdf"
1816
+ - pdfa_errors (list): PDF/A compliance errors
1817
+
1818
+ Example:
1819
+ >>> # Validation with auto-detected profile
1820
+ >>> result = client.validate_facturx_pdf("invoice.pdf")
1821
+ >>> print(f"Detected profile: {result['detected_profile']}")
1822
+
1823
+ >>> # Strict validation with VeraPDF (recommended in production)
1824
+ >>> result = client.validate_facturx_pdf("invoice.pdf", use_verapdf=True)
1825
+ >>> if result['is_compliant']:
1826
+ ... print("Valid Factur-X PDF!")
1827
+ >>> else:
1828
+ ... for err in result.get('pdfa_errors', []):
1829
+ ... print(f"PDF/A error: {err}")
1830
+ """
1831
+ if pdf_path:
1832
+ with open(pdf_path, "rb") as f:
1833
+ pdf_bytes = f.read()
1834
+ if not pdf_bytes:
1835
+ raise ValueError("pdf_path or pdf_bytes required")
1836
+
1837
+ files = {"pdf_file": ("invoice.pdf", pdf_bytes, "application/pdf")}
1838
+ data: Dict[str, Any] = {"use_verapdf": str(use_verapdf).lower()}
1839
+ if profile:
1840
+ data["profile"] = profile
1841
+ response = self._request("POST", "/processing/validate-facturx-pdf", files=files, data=data)
1842
+ return response.json()
1843
+
1844
+
1845
+ def validate_pdf_signature(
1846
+ self,
1847
+ pdf_path: Optional[str] = None,
1848
+ pdf_bytes: Optional[bytes] = None
1849
+ ) -> Dict[str, Any]:
1850
+ """Validate the signature of a signed PDF.
1851
+
1852
+ Args:
1853
+ pdf_path: Path to signed PDF file
1854
+ pdf_bytes: PDF content as bytes
1855
+
1856
+ Returns:
1857
+ Dict with: is_signed (bool), signatures (list), etc.
1858
+
1859
+ Example:
1860
+ >>> result = client.validate_pdf_signature("signed_invoice.pdf")
1861
+ >>> if result['is_signed']:
1862
+ ... print("PDF is signed!")
1863
+ ... for sig in result.get('signatures', []):
1864
+ ... print(f"Signed by: {sig.get('signer_cn')}")
1865
+ """
1866
+ if pdf_path:
1867
+ with open(pdf_path, "rb") as f:
1868
+ pdf_bytes = f.read()
1869
+ if not pdf_bytes:
1870
+ raise ValueError("pdf_path or pdf_bytes required")
1871
+
1872
+ files = {"pdf_file": ("document.pdf", pdf_bytes, "application/pdf")}
1873
+ response = self._request("POST", "/processing/validate-pdf-signature", files=files)
1874
+ return response.json()
1875
+
1876
+
1877
+ # ==================== Signature ====================
1878
+
1879
+ def sign_pdf(
1880
+ self,
1881
+ pdf_path: Optional[str] = None,
1882
+ pdf_bytes: Optional[bytes] = None,
1883
+ reason: Optional[str] = None,
1884
+ location: Optional[str] = None,
1885
+ contact: Optional[str] = None,
1886
+ use_pades_lt: bool = False,
1887
+ use_timestamp: bool = True,
1888
+ output_path: Optional[str] = None
1889
+ ) -> Union[bytes, str]:
1890
+ """Sign a PDF with the server-side configured certificate.
1891
+
1892
+ The certificate must be pre-configured in Django Admin
1893
+ for the client identified by the JWT client_uid.
1894
+
1895
+ Args:
1896
+ pdf_path: Path to PDF to sign
1897
+ pdf_bytes: PDF content as bytes
1898
+ reason: Signature reason (optional)
1899
+ location: Signature location (optional)
1900
+ contact: Contact email (optional)
1901
+ use_pades_lt: Enable PAdES-B-LT for long-term archiving (default: False)
1902
+ use_timestamp: Enable RFC 3161 timestamping (default: True)
1903
+ output_path: If provided, save signed PDF to this path
1904
+
1905
+ Returns:
1906
+ Signed PDF bytes, or path if output_path provided
1907
+
1908
+ Example:
1909
+ >>> signed_pdf = client.sign_pdf(
1910
+ ... pdf_path="invoice.pdf",
1911
+ ... reason="Factur-X Compliance",
1912
+ ... output_path="signed_invoice.pdf"
1913
+ ... )
1914
+ """
1915
+ if pdf_path:
1916
+ with open(pdf_path, "rb") as f:
1917
+ pdf_bytes = f.read()
1918
+
1919
+ if not pdf_bytes:
1920
+ raise ValueError("pdf_path or pdf_bytes required")
1921
+
1922
+ files = {
1923
+ "pdf_file": ("document.pdf", pdf_bytes, "application/pdf"),
1924
+ }
1925
+ data: Dict[str, Any] = {
1926
+ "use_pades_lt": str(use_pades_lt).lower(),
1927
+ "use_timestamp": str(use_timestamp).lower(),
1928
+ }
1929
+ if reason:
1930
+ data["reason"] = reason
1931
+ if location:
1932
+ data["location"] = location
1933
+ if contact:
1934
+ data["contact"] = contact
1935
+
1936
+ response = self._request("POST", "/processing/sign-pdf", files=files, data=data)
1937
+ result = response.json()
1938
+
1939
+ # API returns JSON with signed_pdf_base64
1940
+ pdf_signed_b64 = result.get("signed_pdf_base64")
1941
+ if not pdf_signed_b64:
1942
+ raise FactPulseValidationError("Invalid signature response")
1943
+
1944
+ import base64
1945
+ pdf_signed = base64.b64decode(pdf_signed_b64)
1946
+
1947
+ if output_path:
1948
+ with open(output_path, "wb") as f:
1949
+ f.write(pdf_signed)
1950
+ return output_path
1951
+
1952
+ return pdf_signed
1953
+
1954
+
1955
+ def generate_test_certificate(
1956
+ self,
1957
+ cn: str = "Test Organisation",
1958
+ organisation: str = "Test Organisation",
1959
+ email: str = "test@example.com",
1960
+ validity_days: int = 365,
1961
+ key_size: int = 2048,
1962
+ ) -> Dict[str, Any]:
1963
+ """Generate a test certificate for signing (NOT FOR PRODUCTION).
1964
+
1965
+ The generated certificate must then be configured in Django Admin.
1966
+
1967
+ Args:
1968
+ cn: Certificate Common Name
1969
+ organisation: Organisation name
1970
+ email: Email associated with certificate
1971
+ validity_days: Validity duration in days (default: 365)
1972
+ key_size: RSA key size (2048 or 4096)
1973
+
1974
+ Returns:
1975
+ Dict with certificat_pem, cle_privee_pem, pkcs12_base64, etc.
1976
+
1977
+ Example:
1978
+ >>> result = client.generate_test_certificate(
1979
+ ... cn="My Company - Seal",
1980
+ ... organisation="My Company SAS",
1981
+ ... email="contact@mycompany.com",
1982
+ ... )
1983
+ >>> print(result["certificat_pem"])
1984
+ """
1985
+ data = {
1986
+ "cn": cn,
1987
+ "organisation": organisation,
1988
+ "email": email,
1989
+ "validity_days": validity_days,
1990
+ "key_size": key_size,
1991
+ }
1992
+ response = self._request("POST", "/processing/generate-test-certificate", json_data=data)
1993
+ return response.json()
1994
+
1995
+
1996
+ # ==================== Complete workflow ====================
1997
+
1998
+ def generate_complete_facturx(
1999
+ self,
2000
+ invoice: Dict[str, Any],
2001
+ pdf_source_path: Optional[str] = None,
2002
+ pdf_source_bytes: Optional[bytes] = None,
2003
+ profile: str = "EN16931",
2004
+ validate: bool = True,
2005
+ sign: bool = False,
2006
+ submit_afnor: bool = False,
2007
+ afnor_flow_name: Optional[str] = None,
2008
+ afnor_tracking_id: Optional[str] = None,
2009
+ output_path: Optional[str] = None,
2010
+ timeout: int = 120000
2011
+ ) -> Dict[str, Any]:
2012
+ """Generate a complete Factur-X PDF with optional validation, signing and submission.
2013
+
2014
+ This method automatically chains:
2015
+ 1. Factur-X PDF generation
2016
+ 2. Validation (optional)
2017
+ 3. Signing (optional, uses server-side certificate)
2018
+ 4. AFNOR PDP submission (optional)
2019
+
2020
+ Note: Signing uses the certificate configured in Django Admin
2021
+ for the client identified by the JWT client_uid.
2022
+
2023
+ Args:
2024
+ invoice: Invoice data (FactureFacturX format)
2025
+ pdf_source_path: Path to source PDF
2026
+ pdf_source_bytes: Source PDF as bytes
2027
+ profile: Factur-X profile (MINIMUM, BASIC, EN16931, EXTENDED)
2028
+ validate: If True, validate generated PDF
2029
+ sign: If True, sign PDF (server-side certificate)
2030
+ submit_afnor: If True, submit PDF to AFNOR PDP
2031
+ afnor_flow_name: AFNOR flow name (default: "Invoice {invoice_number}")
2032
+ afnor_tracking_id: AFNOR tracking ID (default: invoice_number)
2033
+ output_path: Output path for final PDF
2034
+ timeout: Polling timeout in ms
2035
+
2036
+ Returns:
2037
+ Dict with:
2038
+ - pdf_bytes: Final PDF bytes
2039
+ - pdf_path: Path if output_path provided
2040
+ - validation: Validation result if validate=True
2041
+ - signature: Signature info if sign=True
2042
+ - afnor: AFNOR submission result if submit_afnor=True
2043
+
2044
+ Example:
2045
+ >>> result = client.generate_complete_facturx(
2046
+ ... invoice=my_invoice,
2047
+ ... pdf_source_path="quote.pdf",
2048
+ ... profile="EN16931",
2049
+ ... validate=True,
2050
+ ... sign=True,
2051
+ ... submit_afnor=True,
2052
+ ... output_path="final_invoice.pdf"
2053
+ ... )
2054
+ >>> if result['validation']['is_compliant']:
2055
+ ... print(f"Invoice submitted! Flow ID: {result['afnor']['flowId']}")
2056
+ """
2057
+ result: Dict[str, Any] = {}
2058
+
2059
+ # 1. Generation
2060
+ if pdf_source_path:
2061
+ with open(pdf_source_path, "rb") as f:
2062
+ pdf_source_bytes = f.read()
2063
+
2064
+ pdf_bytes = self.generate_facturx(
2065
+ invoice_data=invoice,
2066
+ pdf_source=pdf_source_bytes,
2067
+ profile=profile,
2068
+ timeout=timeout
2069
+ )
2070
+ result["pdf_bytes"] = pdf_bytes
2071
+
2072
+ # 2. Validation
2073
+ if validate:
2074
+ validation = self.validate_facturx_pdf(pdf_bytes=pdf_bytes, profile=profile)
2075
+ result["validation"] = validation
2076
+ if not validation.get("est_conforme", False) and not validation.get("is_compliant", False):
2077
+ # Return result with errors
2078
+ if output_path:
2079
+ with open(output_path, "wb") as f:
2080
+ f.write(pdf_bytes)
2081
+ result["pdf_path"] = output_path
2082
+ return result
2083
+
2084
+ # 3. Signing (uses server-side certificate)
2085
+ if sign:
2086
+ pdf_bytes = self.sign_pdf(pdf_bytes=pdf_bytes)
2087
+ result["pdf_bytes"] = pdf_bytes
2088
+ result["signature"] = {"signed": True}
2089
+
2090
+ # 4. AFNOR submission
2091
+ if submit_afnor:
2092
+ invoice_number = invoice.get("invoiceNumber", invoice.get("numeroFacture", invoice.get("numero_facture", "INVOICE")))
2093
+ flow_name = afnor_flow_name or f"Invoice {invoice_number}"
2094
+ tracking_id = afnor_tracking_id or invoice_number
2095
+
2096
+ # Direct submission with bytes (no temp file needed)
2097
+ afnor_result = self.submit_invoice_afnor(
2098
+ flow_name=flow_name,
2099
+ pdf_bytes=pdf_bytes,
2100
+ pdf_filename=f"{invoice_number}.pdf",
2101
+ tracking_id=tracking_id,
2102
+ )
2103
+ result["afnor"] = afnor_result
2104
+
2105
+ # Final save
2106
+ if output_path:
2107
+ with open(output_path, "wb") as f:
2108
+ f.write(pdf_bytes)
2109
+ result["pdf_path"] = output_path
2110
+
2111
+ return result