diff --git a/README.md b/README.md index 0ac92f2..fd763b8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,11 @@ This library is published under the [GNU Lesser General Public License v2.1](htt ## Changelog +* version 0.12 dated 2026-07-05 + + * Add support for e-reporting XML generation (transactions and payments) with XSD check + * generate_cdar(): date fields are now passed in data_dict as datetime object. + * version 0.11 dated 2026-06-29 * new boolean named argument **prefixed_namespaces** in **generate_cdar()** to either generate prefixed namespaces (default) or default namespaces diff --git a/pyproject.toml b/pyproject.toml index c074fb1..8814556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,11 +19,12 @@ classifiers = [ readme = "README.md" license = {file = "LICENSE.txt"} dependencies = [ + "requests", "requests_oauthlib", "python-stdnum >= 1.20", "lxml", - "saxonche", "pytz", + "python-dateutil", ] requires-python = ">=3.9" dynamic = ["version"] diff --git a/src/pyfrctc/__init__.py b/src/pyfrctc/__init__.py index 9be9888..4e12884 100644 --- a/src/pyfrctc/__init__.py +++ b/src/pyfrctc/__init__.py @@ -1,6 +1,15 @@ -__version__ = "0.11" +import logging + +__version__ = "0.12" +from .ereporting import ( + check_ereporting_xsd, + generate_ereporting_payments, + generate_ereporting_transactions, +) from .pyfrctc import ( authorization_code_first_token, + check_cdar_schematron, + check_cdar_xsd, generate_cdar, get_authorization_url, get_directory_lines, @@ -37,8 +46,21 @@ "healthcheck", "parse_cdar", "parse_cdar_raw", + "check_cdar_schematron", + "check_cdar_xsd", "search_flows", "search_flows_parsed", "send_flow", "send_flow_parsed", + "generate_ereporting_transactions", + "generate_ereporting_payments", + "check_ereporting_xsd", ] + + +def configure_script_logging(level=logging.INFO): + logger = logging.getLogger("pyfrctc") + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + logger.addHandler(handler) + logger.setLevel(level) diff --git a/src/pyfrctc/ereporting.py b/src/pyfrctc/ereporting.py new file mode 100644 index 0000000..f0f1324 --- /dev/null +++ b/src/pyfrctc/ereporting.py @@ -0,0 +1,404 @@ +# Copyright 2026 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# Licence LGPL-2.1 or later (https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html). + +import logging + +from lxml import etree, objectify + +from .pyfrctc import _check_xsd + +# VERSION = importlib.metadata.version("pyfrctc") +logger = logging.getLogger("pyfrctc") + +BT_8toCII = { + "invoice": "5", + "delivery": "29", + "payment": "72", +} +EREPORTING_XSD_FILE = "frr-xsd/ereporting.xsd" + + +def _single_invoice(E, inv_dict): + if not isinstance(inv_dict, dict): + raise ValueError("TG-8 must be a list of dicts") + return E.Invoice( + E.ID(inv_dict["BT-1"]), + E.IssueDate(_format_date(inv_dict["BT-2"])), + E.TypeCode(inv_dict["BT-3"]), + E.CurrencyCode(inv_dict["BT-5"]), + *[ + E.DueDate(_format_date(inv_dict["BT-9"])) + for _ in [1] + if inv_dict.get("BT-9") + ], + *[ + E.TaxDueDateTypeCode(BT_8toCII.get(inv_dict["BT-8"], inv_dict["BT-8"])) + for _ in [1] + if inv_dict.get("BT-8") + ], + *[ + E.IncludedNote( + E.Subject(note["BT-21"]), + E.Content(note["BT-22"]), + ) + for note in (inv_dict.get("BG-1") or []) + if note.get("BT-21") and note.get("BT-22") + ], + E.BusinessProcess( + E.ID(inv_dict["BT-23"]), + E.TypeID(inv_dict["BT-24"]), + ), + *[ + E.ReferencedDocument( + E.ID(previnv["BT-25"]), + *[ + E.IssueDate(_format_date(previnv["BT-26"])) + for _ in [1] + if previnv.get("BT-26") + ], + ) + for previnv in (inv_dict.get("BG-3") or []) + if previnv.get("BT-25") + ], + E.Seller( + E.CompanyId(inv_dict["BT-30"], schemeId=inv_dict["BT-30-1"]), + *[E.TaxRegistrationId(inv_dict["BT-31"], qualifyingId="VAT")], + *[ + E.PostalAddress(E.CountryId(inv_dict["BT-40"])) + for _ in [1] + if inv_dict.get("BT-40") + ], + ), + E.Buyer( + E.CompanyId(inv_dict["BT-47"], schemeId=inv_dict["BT-47-1"]), + *[E.TaxRegistrationId(inv_dict["BT-48"], qualifyingId="VAT")], + *[ + E.PostalAddress(E.CountryId(inv_dict["BT-55"])) + for _ in [1] + if inv_dict.get("BT-55") + ], + ), + *[ + E.SellerTaxRepresentative( + E.TaxRegistrationId(inv_dict["BT-63"], schemeId="VAT") + ) + for _ in [1] + if inv_dict.get("BT-63") + ], + *[ + E.Delivery( + *[ + E.Date(_format_date(inv_dict["BT-72"])) + for _ in [1] + if inv_dict.get("BT-72") + ], + *[ + E.Location( + *[ + E.LineOne(inv_dict["BT-75"]) + for _ in [1] + if inv_dict.get("BT-75") + ], + *[ + E.LineTwo(inv_dict["BT-76"]) + for _ in [1] + if inv_dict.get("BT-76") + ], + *[ + E.LineThree(inv_dict["BT-165"]) + for _ in [1] + if inv_dict.get("BT-165") + ], + *[ + E.CityName(inv_dict["BT-77"]) + for _ in [1] + if inv_dict.get("BT-77") + ], + *[ + E.PostalZone(inv_dict["BT-78"]) + for _ in [1] + if inv_dict.get("BT-78") + ], + *[ + E.CountrySubentity(inv_dict["BT-79"]) + for _ in [1] + if inv_dict.get("BT-79") + ], + E.CountryId(inv_dict["BT-80"]), + ) + for _ in [1] + if inv_dict.get("BT-80") + ], + ) + for _ in [1] + if inv_dict.get("BT-72") or inv_dict.get("BT-80") + ], + *[ + E.InvoicePeriod( + *[ + E.StartDate(_format_date(inv_dict["BT-73"])) + for _ in [1] + if inv_dict.get("BT-73") + ], + *[ + E.EndDate(_format_date(inv_dict["BT-74"])) + for _ in [1] + if inv_dict.get("BT-74") + ], + ) + for _ in [1] + if inv_dict.get("BT-73") or inv_dict.get("BT-74") + ], + *[ + E.AllowanceCharge( + E.Amount( + charge["BT-92"] + ), # it is required in EN16931, so it is here too + *[ + E.TaxCategoryCode(charge["BT-95"]) + for _ in [1] + if charge.get("BT-95") + ], + *[E.TaxPercent(charge["BT-96"]) for _ in [1] if charge.get("BT-96")], + ChargeIndicator="false", + ) + for charge in (inv_dict.get("BG-20") or []) + ], + *[ + E.AllowanceCharge( + E.Amount( + charge["BT-99"] + ), # it is required in EN16931, so it is here too + *[ + E.TaxCategoryCode(charge["BT-102"]) + for _ in [1] + if charge.get("BT-102") + ], + *[E.TaxPercent(charge["BT-103"]) for _ in [1] if charge.get("BT-103")], + ChargeIndicator="true", + ) + for charge in (inv_dict.get("BG-21") or []) + ], + E.MonetaryTotal( + E.TaxExclusiveAmount(inv_dict["BT-109"]), + E.TaxAmount( + (inv_dict.get("BT-111") or inv_dict.get("BT-110")), CurrencyCode="EUR" + ), + ), + *[ + E.TaxSubTotal( + E.TaxableAmount(taxsub["BT-116"]), + E.TaxAmount(taxsub["BT-117"]), + E.TaxCategory( + E.Code(taxsub["BT-118"]), + E.Percent(taxsub["BT-119"]), + *[ + E.TaxExemptionReason(taxsub["BT-120"]) + for _ in [1] + if taxsub.get("BT-120") + ], + *[ + E.TaxExemptionReasonCode(taxsub["BT-121"]) + for _ in [1] + if taxsub.get("BT-121") + ], + ), + ) + for taxsub in (inv_dict.get("BG-23") or []) + ], + # BG-25, not required until 01/07/2026 + ) + + +def _single_transaction_10_3(E, trans_dict): + if not isinstance(trans_dict, dict): + raise ValueError("TG-31 must be a list of dicts") + return E.Transactions( + E.Date(_format_date(trans_dict["TT-77"])), + E.TransactionsCurrency(trans_dict["TT-78"]), + *[ + E.TaxDueDateTypeCode( + BT_8toCII.get(trans_dict["TT-80"], trans_dict["TT-80"]) + ) + for _ in [1] + if trans_dict.get("TT-80") + ], + E.CategoryCode(trans_dict["TT-81"]), + E.TaxExclusiveAmount(trans_dict["TT-82"]), + E.TaxTotal(trans_dict["TT-83"]), + *[ + E.TransactionsCount(trans_dict["TT-85"]) + for _ in [1] + if trans_dict.get("TT-85") + ], + *[ + E.TaxSubtotal( + E.TaxPercent(taxsub["TT-86"]), + E.TaxableAmount(taxsub["TT-87"]), + E.TaxTotal(taxsub["TT-88"]), + ) + for taxsub in trans_dict["TG-32"] + ], + ) + + +def _generate_report_document(E, data_dict): + root = E.ReportDocument( + E.Id(data_dict["TT-1"]), # unclear if required or not + *[E.Name(data_dict["TT-2"]) for _ in [1] if data_dict.get("TT-2")], + E.IssueDateTime(E.DateTimeString(_format_datetime(data_dict["TT-3"]))), + E.TypeCode(data_dict["TT-4"]), + *[ + E.References(E.ReportId(data_dict["TT-5"], schemeId=data_dict["TT-6"])) + for _ in [1] + if data_dict.get("TT-5") and data_dict.get("TT-6") + ], + E.Sender( + E.Id(data_dict["TT-8"], schemeId=data_dict["TT-7"]), + E.Name(data_dict["TT-9"]), + E.RoleCode(data_dict["TT-10"]), + *[ + E.URIUniversalCommunication(E.URIID(data_dict["TT-11"])) + for _ in [1] + if data_dict.get("TT-11") + ], + ), + E.Issuer( + E.Id(data_dict["TT-13"], schemeId=data_dict["TT-12"]), + E.Name(data_dict["TT-14"]), + E.RoleCode(data_dict["TT-15"]), + *[ + E.URIUniversalCommunication(E.URIID(data_dict["TT-16"])) + for _ in [1] + if data_dict.get("TT-16") + ], + ), + ) + return root + + +def generate_ereporting_transactions(data_dict, check_xsd=True): + E = objectify.ElementMaker(annotate=False) + root = E.Report( + _generate_report_document(E, data_dict), + E.TransactionsReport( + E.ReportPeriod( + E.StartDate(_format_date(data_dict["TT-17"])), + E.EndDate(_format_date(data_dict["TT-18"])), + ), + *[ + _single_invoice(E, inv_dict) + for inv_dict in (data_dict.get("TG-8") or []) + ], + *[ + _single_transaction_10_3(E, trans_dict) + for trans_dict in (data_dict.get("TG-31") or []) + ], + ), + ) + xml_bytes = etree.tostring( + root, pretty_print=True, encoding="UTF-8", xml_declaration=True + ) + if check_xsd: + check_ereporting_xsd(root) + return xml_bytes + + +### PAYMENTS 10.2 and 10.4 + + +def _single_invoice_payment_10_2(E, payinv_dict): + if not isinstance(payinv_dict, dict): + raise ValueError("TG-34 must be a list of dicts") + return E.Invoice( + E.InvoiceID(payinv_dict["TT-91"]), + E.IssueDate(_format_date(payinv_dict["TT-102"])), + E.Payment( + E.Date(_format_date(payinv_dict["TT-92"])), + *[ + E.SubTotals( + E.TaxPercent(taxsub["TT-93"]), + *[ + E.CurrencyCode(taxsub["TT-94"]) + for _ in [1] + if taxsub.get("TT-94") + ], + E.Amount(taxsub["TT-95"]), + ) + for taxsub in payinv_dict["TG-36"] + ], + ), + ) + + +def _single_payment_10_4(E, pay_dict): + if not isinstance(pay_dict, dict): + raise ValueError("TG-37 must be a list of dicts") + return E.Transactions( + E.Payment( + E.Date(_format_date(pay_dict["TT-96"])), + *[ + E.SubTotals( + E.TaxPercent(taxsub["TT-97"]), + *[ + E.CurrencyCode(taxsub["TT-98"]) + for _ in [1] + if taxsub.get("TT-98") + ], + E.Amount(taxsub["TT-99"]), + ) + for taxsub in pay_dict["TG-39"] + ], + ), + ) + + +def generate_ereporting_payments(data_dict, check_xsd=True): + E = objectify.ElementMaker(annotate=False) + root = E.Report( + _generate_report_document(E, data_dict), + E.PaymentsReport( + E.ReportPeriod( + E.StartDate(_format_date(data_dict["TT-89"])), + E.EndDate(_format_date(data_dict["TT-90"])), + ), + *[ + _single_invoice_payment_10_2(E, payinv_dict) + for payinv_dict in (data_dict.get("TG-34") or []) + ], + *[ + _single_payment_10_4(E, pay_dict) + for pay_dict in (data_dict.get("TG-37") or []) + ], + ), + ) + + xml_bytes = etree.tostring( + root, pretty_print=True, encoding="UTF-8", xml_declaration=True + ) + if check_xsd: + check_ereporting_xsd(root) + return xml_bytes + + +def _format_datetime(date_time): + if not date_time: + raise ValueError("Missing datetime") + return date_time.strftime("%Y%m%d%H%M%S") + + +def _format_date(date): + if not date: + raise ValueError("Missing date") + return date.strftime("%Y%m%d") + + +def check_ereporting_xsd(xml_to_check): + # TODO for some (strange) reasons + # /Report/ReportDocument/References is not accepted by the official XSD. + # In the Excel file, they say: + # "Uniquement pour les échanges entre les utilisateurs et les PA" + # It probably explains... but they could have provided another XSD + # for the exchanges between providers and PA... + return _check_xsd(xml_to_check, EREPORTING_XSD_FILE, "eReporting") diff --git a/src/pyfrctc/frr-xsd/ereporting.xsd b/src/pyfrctc/frr-xsd/ereporting.xsd new file mode 100644 index 0000000..27279ae --- /dev/null +++ b/src/pyfrctc/frr-xsd/ereporting.xsd @@ -0,0 +1,37 @@ + + + + + + + + + + TB-0 : Rapport + + + + + + + + TB-1 : Flux 10 racine + + + + + TB-2 : Transmission des transactions flux 10.1 et 10.3 + + + + + TB-3 : Transmission des paiements flux 10.2 et 10.4 + + + + + + diff --git a/src/pyfrctc/frr-xsd/parametre.xsd b/src/pyfrctc/frr-xsd/parametre.xsd new file mode 100644 index 0000000..4122687 --- /dev/null +++ b/src/pyfrctc/frr-xsd/parametre.xsd @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/pyfrctc/frr-xsd/payment.xsd b/src/pyfrctc/frr-xsd/payment.xsd new file mode 100644 index 0000000..0eab9a6 --- /dev/null +++ b/src/pyfrctc/frr-xsd/payment.xsd @@ -0,0 +1,134 @@ + + + + + + + + TG-33 : Période transmission + + + + + + TT-89 : Date début période + + + + + TT-90 : Date fin période + + + + + + + + TG-34 : Facture + + + + + + TT-91 : Numero de facture + + + + + TT-102 : Date de la facture + + + + + TG-35 : Paiement + + + + + + TT-92 : Date paiement + + + + + TG-36 : Répartition par taux + + + + + + TT-93 : Taux + + + + + TT-94 : Code devise paiement + + + + + TT-95 : Montant encaissé + + + + + + + + + + + + + + TG-37 : Transactions + + + + + + TG-38 : Paiement + + + + + + TT-96 : Date paiement + + + + + TG-39 : Répartition par taux + + + + + + TT-97 : Taux + + + + + TT-98 : Code devise paiement + + + + + TT-99 : Montant encaissé + + + + + + + + + + + + + + + diff --git a/src/pyfrctc/frr-xsd/report.xsd b/src/pyfrctc/frr-xsd/report.xsd new file mode 100644 index 0000000..2aa388c --- /dev/null +++ b/src/pyfrctc/frr-xsd/report.xsd @@ -0,0 +1,140 @@ + + + + + + + + + TT-1 : Identifiant de la transmission généré par l'émetteur + Identifiant de la transmission agrégée généréé par le PPF et les PDP + + + + + TT-2 : Nom du flux généré par l'émetteur + + + + + TG-1 : Horodatage + + + + + + TT-3 : Date et heure de création de la transmission + + + + + + + + TT-4 : Type de transmission + + + + + TG-3 : Identification de l'émetteur du document de transmission (le déclarant ou son OD) ou Identification de l'émetteur du document de transmission agrégée (PPFou PDP) + + + + + + TT-8 : Identifiant de l'émetteur du document de transmission (flux) + + + + + + + TT-7 : Type d'identifiant + + + + + + + + + TT-9 : Raison sociale de l'émetteur du document de transmission (flux) + + + + + TT-10 : Code rôle + + + + + TG-4 : Adresse électronique émetteur + + + + + + TT-11 : Adresse électronique (réseau CEF) + + + + + + + + + + + TG-5 : Identification du déclarant + + + + + + TT-13 : Identifiant du déclarant + + + + + + + TT-12 : Type d'identifiant + + + + + + + + + TT-14 : Raison sociale du déclarant du document de transmission + + + + + TT-15 : Code rôle du déclarant + + + + + TG-6 : Adresse électronique déclarant + + + + + + TT-16 : Adresse électronique (réseau CEF) + + + + + + + + + + + + diff --git a/src/pyfrctc/frr-xsd/transaction.xsd b/src/pyfrctc/frr-xsd/transaction.xsd new file mode 100644 index 0000000..5cb2256 --- /dev/null +++ b/src/pyfrctc/frr-xsd/transaction.xsd @@ -0,0 +1,700 @@ + + + + + + + + TG-7 : Période concernant la transmission + + + + + + TT-17 : Date de début de la période de transmission + + + + + TT-18 : Date de fin de la période de transmission + + + + + + + + TG-8 : Facture + + + + + + TT-19 : Identification de la Facture. + + + + + TT-20 : Date d'émission facture initiale / facture rectificative + + + + + TT-21 : Code de type de facture + + + + + TT-22 : Code de devise de la facture + + + + + TT-201 : Date d'échéance + + + + + TT-24 : Code de date d'exigibilité de la taxe sur la valeur ajoutée + + + + + TG-9 : Note de facture + + + + + + TT-26 : Code du sujet de la note de facture + + + + + TT-27 : Note de facture + + + + + + + + TG-10 : Controle du processus + + + + + + TT-28 : Type de processus métier (cadre de facturation) + + + + + TT-29 : Type de profil (e-invoicing, e-reporting, facture etc..) + + + + + + + + TG-11 : RÉFÉRENCE À UNE FACTURE ANTÉRIEURE + + + + + + TT-30 : Référence à une facture antérieure + + + + + TT-31 : Date d'émission de facture antérieure + + + + + + + + TG-12 : Groupe de termes métiers fournissant des informations sur le Vendeur + + + + + + TT-33 : Identifiant du vendeur + + + + + + + TT-33-1 : Identifiant du schéma + + + + + + + + + TT-34 : Identifiant à la TVA du vendeur + + + + + + + TT-34-0 : Qualifiant d'identifiant à la TVA du vendeur + + + + + + + + + TG-13 : Adresse postale du vendeur + + + + + + TT-35 : Code de pays du vendeur + + + + + + + + + + + TG-14 : Acheteur + + + + + + TT-36 : Identifiant de l'acheteur + + + + + + + TT-37 : Identifiant du schéma + + + + + + + + + TT-38 : Identifiant à la TVA de l'acheteur + + + + + + + TT-38-0 : Qualifiant d'identifiant à la TVA de l'acheteur + + + + + + + + + TG-15 : Adresse postale de l'acheteur + + + + + + TT-39 : Code pays de l'acheteur + + + + + + + + + + + TG-16 : Représentant fiscal du vendeur + + + + + + TT-122 : Identifiant à la TVA du représentant fiscal du vendeur + + + + + + + TT-40 : Identifiant du schéma de l'identifiant TVA du représentant fiscal du vendeur + + + + + + + + + + + + TG-17 : Informations de livraison + + + + + + TT-41 : Date effective de livraison + + + + + TG-19 : Adresse de livraison + + + + + + TT-103 : Adresse de livraison - Ligne 1 + + + + + TT-104 : Adresse de livraison - Ligne 2 + + + + + TT-105 : Adresse de livraison - Ligne 3 + + + + + TT-106 : Localité adresse de livraison + + + + + TT-107 : Code postal adresse de livraison + + + + + TT-108 : Subdivision du pays + + + + + TT-44 : Code de pays + + + + + + + + + + + TG-18 : Période de facturation + + + + + + TT-42 : Date de début de période de facturation + + + + + TT-43 : Date de fin de période de facturation + + + + + + + + TG-20 : Remise au niveau du document ou TG-21 : Charges ou frais au niveau du document + + + + + + TT-45 : Montant de la remise au niveau document ou TT-48 : Montant des charges + + + + + TT-46 : Code de type de TVA de la remise au niveau du document ou TT-49 : Code de type de TVA des charges + + + + + TT-47 : Taux de TVA de la remise au niveau du document ou TT-50 : Taux de TVA des charges ou frais au niveau du document + + + + + + Indicateur de Charge + + + + + + + TG-22 : Totaux du document + + + + + + TT-51 : Montant total de la facture hors TVA + + + + + TT-52 : Montant total de TVA de la facture + + + + + + + TT-202 : Identifiant de la devise + + + + + + + + + + + + TG-23 : Ventilation de la TVA + + + + + + TT-54 : Base d'imposition du type de TVA + + + + + TT-55 : Montant de la TVA pour chaque type de TVA + + + + + TT-194 : Catégorie de Taxe + + + + + + TT-56 : Code de type de TVA + + + + + TT-57 : Taux de type de TVA + + + + + TT-58 : Motif d'exonération de la TVA + + + + + TT-59 : Code de motif d'exonération de la TVA + + + + + + + + + + + TG-24 : Ligne de facture + + + + + + TG-61 : Note de ligne de facture + + + + + + TT-61-0 : Code du sujet de la note de ligne + + + + + TT-61-1 : Note de ligne de facture + + + + + + + + TT-62 : Quantité facturée + + + + + + + TT-63 : Code de l'unité de mesure de la quantité facturée + + + + + + + + + TG-40 : RÉFÉRENCE À UNE FACTURE ANTÉRIEURE + + + + + + TT-300 : ID de la facture antérieure + + + + + TT-301 : Date de facture antérieure + + + + + + + + TG-41 : Détail de l'adresse de livraison à la ligne + + + + + + TT-302 : Nom du lieu de livraison + + + + + TG-42 : ADRESSE POSTALE DE LIVRAISON A LA LIGNE + + + + + + TT-303 : Adresse de livraison - Ligne 1 + + + + + TT-304 : Localité de livraison + + + + + TT-305 : Code postal de livraison + + + + + TT-306 : Subdivision du pays + + + + + TT-307 : Code de pays + + + + + + + + + + + TG-25 : Période de facturation d'une ligne + + + + + + TT-65 : Date de début de période de facturation d'une ligne + + + + + TT-66 : Date de fin de période de facturation d'une ligne + + + + + + + + TG-26 : Remise de ligne de facture ou TG-27 : Charge ou frais d'une ligne de facture + + + + + + TT-67 : Montant d'une remise, hors TVA ou TT-68 : Montant des charges ou frais + + + + + + Indicateur de Charge + + + + + + + TG-28 : Détail du prix + + + + + + TT-69 : Prix net de l'article + + + + + TT-70 : Rabais sur le prix de l'article + + + + + TT-71 : Prix brut de l'article + + + + + + + + TG-30 : Information sur l'article + + + + + + TT-76 : Nom de l'article + + + + + + + + + + + + + + TG-31 : Transactions + + + + + + TT-77 : Date + + + + + TT-78 : Code devise transactions + + + + + TT-80 : Option paiement TVA + + + + + TT-81 : Catégorie de transactions + + + + + TT-82 : Montant total H.T. + + + + + TT-83 : Montant total TVA + + + + + TT-85 : Nombre de transactions + + + + + TG-32 : Répartition TVA + + + + + + TT-86 : Taux + + + + + TT-87 : Base imposition + + + + + TT-88 : Montant TVA + + + + + + + + + + + diff --git a/src/pyfrctc/pyfrctc.py b/src/pyfrctc/pyfrctc.py index 15436e9..3901703 100644 --- a/src/pyfrctc/pyfrctc.py +++ b/src/pyfrctc/pyfrctc.py @@ -12,11 +12,11 @@ import secrets import time from io import BytesIO -from dateutil import parser from urllib.parse import urlencode import pytz -import saxonche +import requests +from dateutil import parser from lxml import etree, objectify from oauthlib.oauth2 import BackendApplicationClient from requests_oauthlib import OAuth2Session @@ -26,10 +26,7 @@ # from pprint import pprint VERSION = importlib.metadata.version("pyfrctc") -FORMAT = "%(asctime)s [%(levelname)s] %(message)s" -logging.basicConfig(format=FORMAT) logger = logging.getLogger("pyfrctc") -logger.setLevel(logging.INFO) PLATFORMS = { "superpdp": { @@ -53,6 +50,15 @@ "CrossDomainAcknowledgementAndResponse:100", "xsi": "http://www.w3.org/2001/XMLSchema-instance", } +CDAR_DATE_FMT = { + "102": "%Y%m%d", + "204": "%Y%m%d%H%M%S", +} + +# Saxon server available from https://github.com/willemvlh/saxon-server +# We stopped using saxonche because of https://github.com/akretion/pyfrctc/issues/3 +SAXON_SERVER_DEFAULT_URL = "http://localhost:5000/transform" +SAXON_SERVER_TIMEOUT = 5 def _get_plateform(session): @@ -744,7 +750,7 @@ def get_directory_lines_parsed( "because the public entity has service required" ) state = "disabled" - expected_identifier = f"{siren}_{siret}" + expected_identifier = f"{siren}_{dir_siret}" else: type = "siren" expected_identifier = siren @@ -1064,7 +1070,11 @@ def get_flow_metadata_parsed(session, flow_id): def generate_cdar( - data_dict, check_xsd=True, check_schematron=True, prefixed_namespaces=True + data_dict, + check_xsd=True, + check_schematron=False, + saxon_server_url=None, + prefixed_namespaces=True, ): """Generate CDAR XML file for life cycle""" if prefixed_namespaces: @@ -1106,7 +1116,11 @@ def generate_cdar( RSM.ExchangedDocument( RAM.ID(data_dict["MDT-4"]), *[RAM.Name(data_dict["MDT-5"]) for _ in [1] if "MDT-5" in data_dict], - RAM.IssueDateTime(UDT.DateTimeString(data_dict["MDT-8"], format="204")), + RAM.IssueDateTime( + UDT.DateTimeString( + _format_datetime_204(data_dict["MDT-8"]), format="204" + ) + ), RAM.SenderTradeParty(RAM.RoleCode(data_dict["MDT-21"])), RAM.IssuerTradeParty( *[ @@ -1133,7 +1147,11 @@ def generate_cdar( UDT.Indicator(str(data_dict["MDT-74"]).lower()) ), RAM.TypeCode(str(data_dict["MDT-77"])), - RAM.IssueDateTime(UDT.DateTimeString(data_dict["MDT-78"], format="204")), + RAM.IssueDateTime( + UDT.DateTimeString( + _format_datetime_204(data_dict["MDT-78"]), format="204" + ) + ), RAM.ReferenceReferencedDocument( RAM.IssuerAssignedID(data_dict["MDT-87"]), *[ @@ -1144,7 +1162,9 @@ def generate_cdar( RAM.TypeCode(data_dict["MDT-91"]), *[ RAM.ReceiptDateTime( - UDT.DateTimeString(data_dict["MDT-95"], format="204") + UDT.DateTimeString( + _format_datetime_204(data_dict["MDT-95"]), format="204" + ) ) for _ in [1] if "MDT-95" in data_dict @@ -1158,7 +1178,9 @@ def generate_cdar( for attach in data_dict.get("MDT-96", []) ], RAM.FormattedIssueDateTime( - QDT.DateTimeString(data_dict["MDT-100"], format="102") + QDT.DateTimeString( + _format_date_102(data_dict["MDT-100"]), format="102" + ) ), RAM.ProcessConditionCode(data_dict["MDT-105"]), RAM.ProcessCondition(data_dict["MDT-106"]), @@ -1224,11 +1246,14 @@ def generate_cdar( *[ RAM.ValueDateTime( UDT.DateTimeString( - doc_characteristic["MDT-219"], format="102" + _format_date_102( + doc_characteristic["MDT-219"] + ), + format="102", ) ) for _ in [1] - if "MDT-219" in doc_characteristic + if doc_characteristic.get("MDT-219") ], ) for doc_characteristic in doc_status.get("MDG-43", []) @@ -1244,52 +1269,101 @@ def generate_cdar( root, pretty_print=True, xml_declaration=True, encoding="UTF-8" ) if check_xsd: - _cdar_check_xsd(xml_bytes) + check_cdar_xsd(root) if check_schematron: - _cdar_check_schematron(xml_bytes) + check_cdar_schematron(xml_bytes, saxon_server_url=saxon_server_url) return xml_bytes -def _cdar_check_xsd(xml_bytes): - xsd_absolute_filepath = importlib.resources.files(__package__).joinpath( - CDAR_XSD_FILE - ) - logger.debug(f"Using CDAR XSD file {xsd_absolute_filepath}") +def _format_date_102(date): + return date.strftime(CDAR_DATE_FMT["102"]) + + +def _format_datetime_204(date_time): + return date_time.strftime(CDAR_DATE_FMT["204"]) + + +def _check_xsd(xml_to_check, xsd_file, file_type): + if isinstance(xml_to_check, (bytes, str)): + try: + xml_root = etree.parse(BytesIO(xml_to_check)) + except Exception as err: + raise Exception( + f"The {file_type} file is not a valid XML file. Error: {err}" + ) from err + elif isinstance(xml_to_check, type(etree.Element("pouet"))): + xml_root = xml_to_check + else: + raise ValueError( + "The first argument must be a bytes, string or an XML etree object" + ) + xsd_absolute_filepath = importlib.resources.files(__package__).joinpath(xsd_file) + logger.debug(f"Using {file_type} XSD file {xsd_absolute_filepath}") official_schema = etree.XMLSchema(file=xsd_absolute_filepath) try: - t = etree.parse(BytesIO(xml_bytes)) - official_schema.assertValid(t) - except Exception as e: + official_schema.assertValid(xml_root) + except Exception as err: # if the validation of the XSD fails, we arrive here - logger.error("The CDAR XML file is invalid against the XML Schema Definition") - logger.error(f"XSD Error: {str(e)}") + logger.error( + f"The {file_type} XML file is invalid against the XML Schema Definition" + ) + logger.error(f"XSD Error: {str(err)}") raise Exception( - "The CDAR XML file is not valid against the official " + f"The {file_type} XML file is not valid against the official " "XML Schema Definition. " "Here is the error, which may give you an idea on the " - f"cause of the problem: {str(e)}." - ) from e - logger.info("CDAR XML file successfully checked against XSD") + f"cause of the problem: {str(err)}." + ) from err + logger.info(f"{file_type} XML file successfully checked against XSD") + + +def check_cdar_xsd(xml_to_check): + return _check_xsd(xml_to_check, CDAR_XSD_FILE, "CDAR") -def _cdar_check_schematron(xml_bytes): - # TODO add option to pass saxon_proc_and_style +def check_cdar_schematron(xml_bytes, saxon_server_url=None, raise_if_http_error=False): + if not isinstance(saxon_server_url, (type(None), str)): + raise ValueError("saxon_server_url argument must be a string or None") + url = saxon_server_url + if url is None: + url = SAXON_SERVER_DEFAULT_URL start_chrono = datetime.datetime.now() errors = [] xml_str = xml_bytes.decode("utf-8") xml_str_no_bom = xml_str.lstrip("\ufeff") xsl_file_path = importlib.resources.files(__package__).joinpath(CDAR_XSL_FILE) - xsl_file_path_str = str(xsl_file_path) - with saxonche.PySaxonProcessor() as saxproc: - xslt_proc = saxproc.new_xslt30_processor() - xdm_node = saxproc.parse_xml(xml_text=xml_str_no_bom) - # compile_stylesheet() is the slow/heavy part - # So, if you pass the compiled stylesheet as argument, it saves a lot of time - # (about 300 ms on an intel laptop) - saxon_compiled_stylesheet = xslt_proc.compile_stylesheet( - stylesheet_file=xsl_file_path_str + xsl_file_str = xsl_file_path.read_text(encoding="utf-8") + + rfiles = { + "xml": ("cdar_file.xml", xml_str_no_bom, "text/xml"), + "xsl": ("cdar_schematron.xsl", xsl_file_str, "text/xml"), + } + logger.info( + f"Sending HTTP POST request on {url} to validate against CDAR schematron" + ) + try: + res = requests.post(url, files=rfiles, timeout=SAXON_SERVER_TIMEOUT) + except Exception as err: + error_msg = f"Failure in the POST request to saxon server on {url}: {str(err)}" + logger.warning(error_msg) + if raise_if_http_error: + raise RuntimeError(error_msg) from err + logger.warning("Skipping CDAR schematron check") + return + + if res.status_code != 200: + error_msg = ( + f"Saxon server returned HTTP code {res.status_code} " + "(expected HTTP code: 200)" ) - result_str = saxon_compiled_stylesheet.transform_to_string(xdm_node=xdm_node) + logger.warning(error_msg) + if raise_if_http_error: + raise RuntimeError(error_msg) + logger.warning("Skipping CDAR schematron check") + return + logger.info("Saxon server answered successfully") + result_str = res.text + logger.debug("schematron result_str=%s", result_str) try: svrl_root = etree.fromstring(result_str.encode("utf-8")) @@ -1336,7 +1410,9 @@ def _cdar_check_schematron(xml_bytes): ) -def parse_cdar_raw(xml_bytes, check_xsd=True, check_schematron=True): +def parse_cdar_raw( + xml_bytes, check_xsd=True, check_schematron=False, saxon_server_url=None +): if not xml_bytes: raise ValueError("xml_bytes argument has no value") if isinstance(xml_bytes, str): @@ -1350,9 +1426,9 @@ def parse_cdar_raw(xml_bytes, check_xsd=True, check_schematron=True): except Exception as e: raise RuntimeError(f"CDAR file is not a valid XML file. Error: {str(e)}") from e if check_xsd: - _cdar_check_xsd(xml_bytes) + check_cdar_xsd(xml_bytes) if check_schematron: - _cdar_check_schematron(xml_bytes) + check_cdar_schematron(xml_bytes, saxon_server_url=saxon_server_url) exch_doc_xp = "//rsm:CrossDomainAcknowledgementAndResponse/rsm:ExchangedDocument" ack_doc_xp = ( "//rsm:CrossDomainAcknowledgementAndResponse/rsm:AcknowledgementDocument" @@ -1426,10 +1502,6 @@ def parse_cdar_raw(xml_bytes, check_xsd=True, check_schematron=True): def _xpath_get_value(xpath, node, namespaces): - date_fmt = { - "102": "%Y%m%d", - "204": "%Y%m%d%H%M%S", - } xpath_res = node.xpath(xpath, namespaces=namespaces) values = [] for xpath_entry in xpath_res: @@ -1439,10 +1511,10 @@ def _xpath_get_value(xpath, node, namespaces): value and xpath.endswith(":DateTimeString") and xpath_entry.attrib - and xpath_entry.attrib.get("format") in date_fmt + and xpath_entry.attrib.get("format") in CDAR_DATE_FMT ): value = datetime.datetime.strptime( - value, date_fmt[xpath_entry.attrib["format"]] + value, CDAR_DATE_FMT[xpath_entry.attrib["format"]] ) elif value and xpath_entry.attrib and xpath_entry.attrib.get("currencyID"): value = { @@ -1477,9 +1549,14 @@ def _map_nested_keys(data, key_map): return data -def parse_cdar(xml_bytes, check_xsd=True, check_schematron=True): +def parse_cdar( + xml_bytes, check_xsd=True, check_schematron=False, saxon_server_url=None +): raw_res = parse_cdar_raw( - xml_bytes, check_xsd=check_xsd, check_schematron=check_schematron + xml_bytes, + check_xsd=check_xsd, + check_schematron=check_schematron, + saxon_server_url=saxon_server_url, ) key_map = { "MDT-87": "invoice_number", diff --git a/tests/test_ereporting.py b/tests/test_ereporting.py new file mode 100644 index 0000000..db444d1 --- /dev/null +++ b/tests/test_ereporting.py @@ -0,0 +1,488 @@ +# Copyright 2026 Akretion France (https://www.akretion.com). +# @author: Alexis de Lattre + +import unittest +from datetime import datetime + +from pyfrctc import generate_ereporting_payments, generate_ereporting_transactions + +DATE_FMT = "%Y-%m-%d" + + +class TestGenerateEreporting(unittest.TestCase): + def _prepare_invoice_dict(self): + inv_dict = { + "BT-1": "F124212", + "BT-2": datetime.strptime("2026-06-17", DATE_FMT), + "BT-3": "380", + "BT-5": "EUR", + # 'BT-6': "EUR", + # for BT-8, as the values are different in UBL and CII + # we use a codification: 'invoice', 'delivery' or 'payment' + "BT-8": "invoice", + "BT-9": datetime.strptime("2026-07-16", DATE_FMT), + "BT-10": "120943", + "BT-11": "Projet_Zorro", + "BT-11-0": "Zorro super projet", + "BT-12": "Contrat_du_siècle", + "BT-13": "PO1242", + "BT-14": "DEVIS2093", + "BT-15": "BR-2026/06/42", + "BT-16": "BL-2026/05/12", + "BT-19": "401HOF", + "BT-20": "30 jours net", + "BT-23": "S1", + "BT-24": "urn.cpro.gouv.fr:1p0:ereporting", # to generate + # START Seller BG-4 + "BT-27": "Au bon moulin", + "BT-28": "L'huile d'olive en folie", + "BT-29": { + # key = schemeID, value = GlobalID value + # if not key: value = ID + # None: 'REF_SELLER', + "0009": "99999999800019", + "0224": "Code_ROUTAGE_seller", + }, + "BT-30": "999999998", + "BT-30-1": "0002", + "BT-31": "FR11999999998", + "BT-32": "DGFIP_ref_1242", + "BT-34": "999999998_042", + "BT-34-1": "0225", + "BT-35": "1242 chemin de l'olive", + "BT-36": "Lieu dit des senteurs", + "BT-162": "ZAC du Mont Ventoux", + "BT-37": "Malaucène", + "BT-38": "84340", + "BT-39": "Vaucluse", + "BT-40": "FR", + "BT-41": "M. Rémi Dupont", + "BT-42": "+33 6 12 42 12 42", + "BT-43": "commercial@aubonmoulin.com", + # START Buyer BG-7 + "BT-44": "Ma jolie boutique SARL", + "BT-45": "Ma jolie Trading Brand", + "BT-46": { + # for buyer: either ID (key None) or GlobalID, not both + # None: "REF_BUYER", + "0009": "78787878400018", + # TODO FX schematron doesn't allow multiple schemes for buyer + # but it allows that for seller... I don't understand this ! + # "0224": 'code_routage_buyer', + }, + "BT-47": "787878784", + "BT-47-1": "0002", + "BT-48": "FR19787878784", + "BT-49": "787878784", + "BT-49-1": "0225", + "BT-50": "35 rue de la République", + "BT-51": "La presqu'île", + "BT-52": "Lyon", + "BT-53": "69001", + "BT-54": "Rhône", + "BT-55": "FR", + # either personName (BT-56) OR DepartmentName (BT-56-0), not both + "BT-56": "Mme Laëtitia Durand", + "BT-57": "+33 7 42 12 42 12", + "BT-58": "laetita.durand@jolieboutique.com", + # End Buyer + # Seller Agent EXT-FR-FE-BG-03 + "EXT-FR-FE-66": "M. Rémi Agent", + "EXT-FR-FE-86": "Rémi Agent", + "EXT-FR-FE-87": "+33 7 87 32 34 54", + "EXT-FR-FE-88": "remi@superagent.com", + # Buyer Agent EXT-FR-FE-BG-01 + "EXT-FR-FE-03": "M. Négociateur CHEF", + "EXT-FR-FE-23": "Charlotte Dupont", + "EXT-FR-FE-24": "+33 7 88 55 33 22", + "EXT-FR-FE-25": "charlotte@supernegociatrice.eu", + # Invoicer EXT-FR-FE-BG-05 + "EXT-FR-FE-112": "Facturier SARL", + "EXT-FR-FE-130": "FR", + "EXT-FR-FE-128": "97400", + "EXT-FR-FE-127": "St Denis", + "EXT-FR-FE-124": "12 rue Sainte Marie", + "EXT-FR-FE-117": "704721240", + "EXT-FR-FE-118": "0002", + # Invoicee EXT-FR-FE-BG-04 + "EXT-FR-FE-89": "Receipee EURL", + "EXT-FR-FE-107": "FR", + "EXT-FR-FE-105": "69001", + "EXT-FR-FE-104": "Lyon", + "EXT-FR-FE-101": "42 boulevard de la Croix Rousse", + "EXT-FR-FE-94": "528010523", + "EXT-FR-FE-95": "0002", + # Start Tax Representative + "BT-62": "Société écran", + "BT-69": "FR", + "BT-63": "FR15123456789", + "BT-73": datetime.strptime("2026-06-01", DATE_FMT), + "BT-74": datetime.strptime("2026-06-30", DATE_FMT), + # Start Ship to + "BT-70": "Plateforme logistique FastIT", + "BT-71": { + "0009": "63636363200010", + }, + "BT-80": "FR", + "BT-77": "Carpentras", + "BT-78": "84200", + "BT-75": "5 avenue Georges Clémenceau", + "BT-76": "ZAC du Venoux", + "BT-165": "Bâtiment A", + "BT-72": datetime.strptime("2026-06-14", DATE_FMT), + "BT-83": datetime.strptime("2026-06-17", DATE_FMT), + "BT-81": "30", + "BT-82": "Libellé du moyen de paiement", + # "BT-87": "567890", + # "BT-88": "Alexis de Lattre", + "BT-84": "FR2012421242124212421242124", + "BT-85": "Au bon moulin SARL", + "BT-86": "QNTOFRP1XXX", + "BT-89": "RUM_9083209", + # Payeur EXT-FR-FE-BG-02 + "EXT-FR-FE-43": "Société payeur", + "EXT-FR-FE-61": "FR", + "EXT-FR-FE-59": "05100", + "EXT-FR-FE-58": "Névache", + "EXT-FR-FE-55": "Vallée étroite", + "EXT-FR-FE-52": "754760932", + "EXT-FR-FE-53": "0225", + "EXT-FR-FE-48": "754760932", + "EXT-FR-FE-49": "0002", + "BT-91": "FR7312345678901275089715A98", + "BT-90": "Au bon moulin SARL", + "BG-1": [ + { + "BT-21": "AAI", + "BT-22": "Ceci est une information générale", + }, + { + "BT-21": "ADN", + "BT-22": "B2G", + }, + {"BT-22": "note sans sujet !"}, + { + "BT-21": "PMT", + "BT-22": "Indemnité forfaitaire pour frais de recouvrement " + "en cas de retard de paiement : 40 €.", + }, + { + "BT-21": "PMD", + "BT-22": "Tout retard de paiement engendre une pénalité " + "exigible à compter de la date d'échéance, " + "calculée sur la base de trois fois le taux d'intérêt légal.", + }, + { + "BT-21": "AAB", + "BT-22": "Les réglements reçus avant la date d'échéance " + "ne donneront pas lieu à escompte.", + }, + ], + "BG-23": [ + { + "BT-117": "27.6", + "BT-117-1": "EUR", # TODO + "BT-116": "138.00", + "BT-116-1": "EUR", # TODO + "BT-118": "S", + "BT-119": "20.00", + }, + { + "BT-117": "7.43", + "BT-117-1": "EUR", # TODO + "BT-116": "135.00", + "BT-116-1": "EUR", # TODO + "BT-118": "S", + "BT-119": "5.50", + }, + ], + "BT-106": "273.00", + "BT-107": "5.50", + "BT-108": "5.50", + "BT-109": "273.00", + "BT-111": "35.03", + "BT-111-1": "EUR", + "BT-112": "308.03", + "BT-113": "100.00", + "BT-115": "208.03", + "BG-3": [ + { + "BT-25": "F124211", + "EXT-FR-FE-02": "386", + "BT-26": datetime.strptime("2025-12-30", DATE_FMT), + }, + { + "BT-25": "F124210", + # 'BT-26': datetime.strptime("2025-09-01", DATE_FMT), + }, + ], + "BT-17": "LOT12", + "BT-18-00": { # key = BT-18-1: value = BT-18 + "AHK": "EMPL042", + "AHO": "Chamber0823", + }, + "BG-24": [ + { + "BT-122": "JUSTIF42", + "BT-123": "DOCUMENT_ANNEXE", # allowed codes in BR-FR-17 + "BT-124": "https://www.share.com/justif42.pdf", + }, + { + "BT-122": "JUSTIF43", + "BT-123": "BON_LIVRAISON", + "BT-124": "https://www.share.com/justif43.pdf", + }, + ], + "BG-20": [ + { + "BT-92": "3.00", + "BT-93": "138.00", + "BT-97": "Consigne", + "BT-95": "S", + "BT-96": "20.00", + }, + { + "BT-92": "2.50", + "BT-93": "138.00", + "BT-97": "Réduc fidélité", + "BT-95": "S", + "BT-96": "20.00", + }, + ], + "BG-21": [ + { + "BT-99": "5.50", + "BT-100": "138.00", + "BT-104": "Surtaxe carburant", + "BT-102": "S", + "BT-103": "20.00", + } + ], + "BG-25": [ # Invoice lines + { + "BT-126": "1", + "BT-127": "Olives récoltées exclusivement dans le Vaucluse (FR)", + "BT-155": "JOIO50CL", + "BT-153": "Huile d'olive Joio 50cl", + "BT-157": "3518370900150", + "BT-157-1": "0160", + "BT-159": "FR", + "BT-146": "13.50", + "BT-147": "1.50", + "BT-148": "15.00", + "BT-129": "10", + "BT-130": "C62", + "BT-133": "623400", + "BT-151": "S", + "BT-152": "5.50", + "BT-131": "135.00", # Total HT + "BT-134": datetime.strptime("2026-06-14", DATE_FMT), + "BT-135": datetime.strptime("2026-06-15", DATE_FMT), + "BG-32": { # key = BT-160: value = BT-161 + "Couleur": "Vert", + "Taille": "L", + }, + "BT-158-00": { # key = (BT-158-1, BT-158-2): value = BT-158 + ("BB", "1.0"): "LOT1242", + ("HS", "NC8"): "15092000", + }, + "BG-27": [ + { + "BT-136": "1.50", + "BT-139": "test", + }, + ], + "BG-28": [ + { + "BT-141": "1.50", + "BT-144": "test inverse", + }, + ], + }, + { + "BT-126": "2", + "BT-127": "Nougat préparé par les moines et les " + "moniales du Barroux (FR)", + "BT-155": "NOUGATCUBES", + "BT-153": "Nougats en cubes", + "BT-157": "3518370400049", + "BT-157-1": "0160", + "BT-159": "FR", + "BT-146": "6.90", + "BT-147": "1.05", + "BT-148": "7.95", + "BT-128-00": { # key = BT-128-1: value = BT-128 + "MWB": "AWB129871", + }, + "BT-129": "20", + "BT-130": "C62", + "BT-133": "623400", + "BT-151": "S", + "BT-152": "20.00", + "BT-131": "138.00", # Total HT + "BT-132": "DV843873", + "EXT-FR-FE-135": "PO982749", + "EXT-FR-FE-140": "BL0982432", + "EXT-FR-FE-141": "AVIS9074398", + # EXT-FR-FE-BG-10 + "EXT-FR-FE-149": "Alpes du Sud Logistique", + "EXT-FR-FE-155": "05600", + "EXT-FR-FE-151": "12 rue de Vanban", + "EXT-FR-FE-154": "Eygliers", + "EXT-FR-FE-157": "FR", + # ref to previous invoice + "EXT-FR-FE-136": "F824739", + "EXT-FR-FE-139": "12", + "EXT-FR-FE-137": "380", + "EXT-FR-FE-138": datetime.strptime("2025-12-24", DATE_FMT), + }, + ], + } + return inv_dict + + def _prepare_document(self): + datetime_fmt = "%Y-%m-%d %H:%M:%S" + data_dict = { + "TT-1": "IDentifier", + "TT-2": "ID Name", + "TT-3": datetime.strptime("2026-07-03 21:35:42", datetime_fmt), + # "TT-4": "RE", + "TT-4": "IN", + # "TT-5": "IDprevious", + # "TT-6": "IN", + # Sender TG-3 + "TT-8": "0111", # Matricule of SUPER PDP + "TT-7": "0238", + "TT-9": "SUPER PDP", + "TT-10": "WK", + "TT-11": "853322915", + # Issuer TG-5 + "TT-13": "792377731", + "TT-12": "0002", + "TT-14": "Akretion France", + "TT-15": "SE", + "TT-16": "792377731", + } + return data_dict + + def _prepare_transactions(self): + data_dict = self._prepare_document() + data_dict.update( + { + "TT-17": datetime.strptime("2026-08-01", DATE_FMT), + "TT-18": datetime.strptime("2026-08-10", DATE_FMT), + # List of invoices + "TG-8": [ + self._prepare_invoice_dict(), + ], + "TG-31": [ + { + "TT-77": datetime.strptime("2026-08-01", DATE_FMT), + "TT-78": "EUR", + "TT-80": "invoice", + "TT-81": "TLB1", + "TT-82": "1500.00", + "TT-83": "227.50", + "TG-32": [ + { + "TT-86": "20.00", + "TT-87": "1000.00", + "TT-88": "200.00", + }, + { + "TT-86": "5.50", + "TT-87": "500.00", + "TT-88": "27.50", + }, + ], + }, + ], + } + ) + return data_dict + + def _prepare_payments(self): + data_dict = self._prepare_document() + data_dict.update( + { + "TT-89": datetime.strptime("2026-08-01", DATE_FMT), + "TT-90": datetime.strptime("2026-08-10", DATE_FMT), + "TG-34": [ + { + "TT-91": "F2026-005", + "TT-102": datetime.strptime("2026-06-25", DATE_FMT), + "TT-92": datetime.strptime("2026-08-03", DATE_FMT), + "TG-36": [ + { + "TT-93": "20.00", + "TT-94": "EUR", + "TT-95": "983.43", + }, + { + "TT-93": "5.50", + "TT-94": "EUR", + "TT-95": "876.44", + }, + ], + }, + { + "TT-91": "F2026-012", + "TT-102": datetime.strptime("2026-07-02", DATE_FMT), + "TT-92": datetime.strptime("2026-08-04", DATE_FMT), + "TG-36": [ + { + "TT-93": "20.00", + "TT-94": "EUR", + "TT-95": "55.66", + }, + { + "TT-93": "5.50", + "TT-94": "EUR", + "TT-95": "42.42", + }, + ], + }, + ], + "TG-37": [ + { + "TT-96": datetime.strptime("2026-08-01", DATE_FMT), + "TG-39": [ + { + "TT-97": "20.00", + "TT-98": "EUR", + "TT-99": "234.99", + }, + { + "TT-97": "5.50", + "TT-98": "EUR", + "TT-99": "456.78", + }, + ], + }, + { + "TT-96": datetime.strptime("2026-08-02", DATE_FMT), + "TG-39": [ + { + "TT-97": "20.00", + "TT-98": "EUR", + "TT-99": "909.55", + }, + { + "TT-97": "5.50", + "TT-98": "EUR", + "TT-99": "789.12", + }, + ], + }, + ], + } + ) + return data_dict + + def test_generate_ereporting_transactions(self): + data_dict = self._prepare_transactions() + _xml_bytes = generate_ereporting_transactions(data_dict) + + def test_generate_ereporting_payments(self): + data_dict = self._prepare_payments() + _xml_bytes = generate_ereporting_payments(data_dict)