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