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