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