From 8e79796781ce4708a367fd7e52ba1dd3f554b783 Mon Sep 17 00:00:00 2001 From: JbLb Date: Sat, 14 Mar 2026 00:41:41 +0100 Subject: [PATCH 1/9] =?UTF-8?q?g=C3=A9n=C3=A9ration=20du=20fichier=20XML?= =?UTF-8?q?=20pour=20le=20format=20factur-x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- generate_facturx_basic.py | 345 ++++++++++++++++++++++++++++++++++++++ routers/factures.py | 60 +++++++ 2 files changed, 405 insertions(+) create mode 100644 generate_facturx_basic.py diff --git a/generate_facturx_basic.py b/generate_facturx_basic.py new file mode 100644 index 0000000..7d78512 --- /dev/null +++ b/generate_facturx_basic.py @@ -0,0 +1,345 @@ +""" +Usage +pip install lxml +python generate_facturx_basic.py +""" + +""" +Générateur de XML Factur-X - Profil BASIC +Conforme à la norme EN 16931, syntaxe CII (Cross Industry Invoice) +Factur-X 1.0 / ZUGFeRD 2.x +""" + +from lxml import etree +from datetime import date +from dataclasses import dataclass, field +from typing import List, Optional + + +# ───────────────────────────────────────────── +# Namespaces CII obligatoires +# ───────────────────────────────────────────── +NSMAP = { + "rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100", + "ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100", + "udt": "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100", + "qdt": "urn:un:unece:uncefact:data:standard:QualifiedDataType:100", + "xsi": "http://www.w3.org/2001/XMLSchema-instance", +} +RSM = NSMAP["rsm"] +RAM = NSMAP["ram"] +UDT = NSMAP["udt"] + +PROFILE_BASIC = "urn:factur-x.eu:1p0:basic" + + +# ───────────────────────────────────────────── +# Structures de données +# ───────────────────────────────────────────── +@dataclass +class Address: + line1: str + city: str + postcode: str + country_code: str # ISO 3166-1 alpha-2, ex: "FR" + line2: Optional[str] = None + + +@dataclass +class Party: + name: str + vat_id: Optional[str] = None # ex: "FR12345678901" + siret: Optional[str] = None # schemeID="0002" + address: Optional[Address] = None + + +@dataclass +class TaxLine: + base_amount: float + rate: float # ex: 20.0 pour 20% + amount: float + category: str = "E" # S=standard, Z=zéro, E=exonéré, AE=autoliquidation + + +@dataclass +class InvoiceLine: + line_id: str + description: str + quantity: float + unit_code: str # ex: "C62"=unité, "HUR"=heure, "KGM"=kg + unit_price: float # prix HT unitaire + vat_rate: float # ex: 20.0 + vat_category: str = "S" + # Le montant HT de la ligne est calculé automatiquement + + +@dataclass +class Invoice: + # En-tête + number: str + issue_date: date + due_date: Optional[date] + currency: str # ex: "EUR" + type_code: str = "380" # 380=facture, 381=avoir, 384=facture rectificative + + # Parties + seller: Party = None + buyer: Party = None + + # Lignes + lines: List[InvoiceLine] = field(default_factory=list) + + # Paiement + payment_means_code: str = "30" # 30=virement + iban: Optional[str] = None + bic: Optional[str] = None + payment_reference: Optional[str] = None + + # Note (champ libre) + note: Optional[str] = None + + +# ───────────────────────────────────────────── +# Helpers XML +# ───────────────────────────────────────────── +def sub(parent, ns, tag, text=None, **attribs): + el = etree.SubElement(parent, f"{{{ns}}}{tag}", **attribs) + if text is not None: + el.text = str(text) + return el + + +def date_str(d: date) -> str: + return d.strftime("%Y%m%d") + + +def amount_str(v: float) -> str: + return f"{v:.2f}" + + +# ───────────────────────────────────────────── +# Construction du XML +# ───────────────────────────────────────────── +def build_facturx(inv: Invoice) -> etree._ElementTree: + + root = etree.Element(f"{{{RSM}}}CrossIndustryInvoice", nsmap=NSMAP) + + # ── 1. ExchangedDocumentContext ────────────── + ctx = sub(root, RSM, "ExchangedDocumentContext") + gp = sub(ctx, RAM, "GuidelineSpecifiedDocumentContextParameter") + sub(gp, RAM, "ID", PROFILE_BASIC) + + # ── 2. ExchangedDocument ──────────────────── + doc = sub(root, RSM, "ExchangedDocument") + sub(doc, RAM, "ID", inv.number) + sub(doc, RAM, "TypeCode", inv.type_code) + idt = sub(doc, RAM, "IssueDateTime") + sub(idt, UDT, "DateTimeString", date_str(inv.issue_date), format="102") + if inv.note: + sub(doc, RAM, "IncludedNote").append( + etree.fromstring(f'{inv.note}') + ) + + # ── 3. SupplyChainTradeTransaction ────────── + tx = sub(root, RSM, "SupplyChainTradeTransaction") + + # ── 3a. Lignes de facture (BASIC = lignes obligatoires) ── + for ln in inv.lines: + net = round(ln.quantity * ln.unit_price, 2) + ili = sub(tx, RAM, "IncludedSupplyChainTradeLineItem") + assoc = sub(ili, RAM, "AssociatedDocumentLineDocument") + sub(assoc, RAM, "LineID", ln.line_id) + + prod = sub(ili, RAM, "SpecifiedTradeProduct") + sub(prod, RAM, "Name", ln.description) + + la = sub(ili, RAM, "SpecifiedLineTradeAgreement") + gpp = sub(la, RAM, "GrossPriceProductTradePrice") + sub(gpp, RAM, "ChargeAmount", amount_str(ln.unit_price), currencyID=inv.currency) + npp = sub(la, RAM, "NetPriceProductTradePrice") + sub(npp, RAM, "ChargeAmount", amount_str(ln.unit_price), currencyID=inv.currency) + + ld = sub(ili, RAM, "SpecifiedLineTradeDelivery") + sub(ld, RAM, "BilledQuantity", amount_str(ln.quantity), unitCode=ln.unit_code) + + ls = sub(ili, RAM, "SpecifiedLineTradeSettlement") + tax = sub(ls, RAM, "ApplicableTradeTax") + sub(tax, RAM, "TypeCode", "VAT") + sub(tax, RAM, "CategoryCode", ln.vat_category) + sub(tax, RAM, "RateApplicablePercent", amount_str(ln.vat_rate)) + + smls = sub(ls, RAM, "SpecifiedTradeSettlementLineMonetarySummation") + sub(smls, RAM, "LineTotalAmount", amount_str(net), currencyID=inv.currency) + + # ── 3b. ApplicableHeaderTradeAgreement ────── + agr = sub(tx, RAM, "ApplicableHeaderTradeAgreement") + + def _party(parent_el, tag, party: Party): + p = sub(parent_el, RAM, tag) + sub(p, RAM, "Name", party.name) + if party.siret: + sr = sub(p, RAM, "SpecifiedLegalOrganization") + sub(sr, RAM, "ID", party.siret, schemeID="0002") + if party.vat_id: + tr = sub(p, RAM, "SpecifiedTaxRegistration") + sub(tr, RAM, "ID", party.vat_id, schemeID="VA") + if party.address: + addr = sub(p, RAM, "PostalTradeAddress") + sub(addr, RAM, "PostcodeCode", party.address.postcode) + sub(addr, RAM, "LineOne", party.address.line1) + if party.address.line2: + sub(addr, RAM, "LineTwo", party.address.line2) + sub(addr, RAM, "CityName", party.address.city) + sub(addr, RAM, "CountryID", party.address.country_code) + return p + + _party(agr, "SellerTradeParty", inv.seller) + _party(agr, "BuyerTradeParty", inv.buyer) + + if inv.payment_reference: + bor = sub(agr, RAM, "BuyerOrderReferencedDocument") + sub(bor, RAM, "IssuerAssignedID", inv.payment_reference) + + # ── 3c. ApplicableHeaderTradeDelivery ─────── + sub(tx, RAM, "ApplicableHeaderTradeDelivery") + + # ── 3d. ApplicableHeaderTradeSettlement ───── + stl = sub(tx, RAM, "ApplicableHeaderTradeSettlement") + sub(stl, RAM, "InvoiceCurrencyCode", inv.currency) + + # Moyens de paiement + pm = sub(stl, RAM, "SpecifiedTradeSettlementPaymentMeans") + sub(pm, RAM, "TypeCode", inv.payment_means_code) + if inv.iban: + acc = sub(pm, RAM, "PayeePartyCreditorFinancialAccount") + sub(acc, RAM, "IBANID", inv.iban) + if inv.bic: + fi = sub(pm, RAM, "PayeeSpecifiedCreditorFinancialInstitution") + sub(fi, RAM, "BICID", inv.bic) + + # TVA par taux (calcul à partir des lignes) + tax_totals: dict[tuple, list] = {} + for ln in inv.lines: + key = (ln.vat_rate, ln.vat_category) + tax_totals.setdefault(key, []).append(round(ln.quantity * ln.unit_price, 2)) + + total_ht = 0.0 + total_tva = 0.0 + for (rate, cat), bases in tax_totals.items(): + base = round(sum(bases), 2) + tva = round(base * rate / 100, 2) + total_ht += base + total_tva += tva + tax = sub(stl, RAM, "ApplicableTradeTax") + sub(tax, RAM, "CalculatedAmount", amount_str(tva), currencyID=inv.currency) + sub(tax, RAM, "TypeCode", "VAT") + sub(tax, RAM, "BasisAmount", amount_str(base), currencyID=inv.currency) + sub(tax, RAM, "CategoryCode", cat) + sub(tax, RAM, "RateApplicablePercent", amount_str(rate)) + + total_ht = round(total_ht, 2) + total_tva = round(total_tva, 2) + total_ttc = round(total_ht + total_tva, 2) + + # Échéance + if inv.due_date: + stp = sub(stl, RAM, "SpecifiedTradePaymentTerms") + ddt = sub(stp, RAM, "DueDateDateTime") + sub(ddt, UDT, "DateTimeString", date_str(inv.due_date), format="102") + + # Totaux + sums = sub(stl, RAM, "SpecifiedTradeSettlementHeaderMonetarySummation") + sub(sums, RAM, "LineTotalAmount", amount_str(total_ht), currencyID=inv.currency) + sub(sums, RAM, "TaxBasisTotalAmount",amount_str(total_ht), currencyID=inv.currency) + sub(sums, RAM, "TaxTotalAmount", amount_str(total_tva), currencyID=inv.currency) + sub(sums, RAM, "GrandTotalAmount", amount_str(total_ttc), currencyID=inv.currency) + sub(sums, RAM, "DuePayableAmount", amount_str(total_ttc), currencyID=inv.currency) + + return etree.ElementTree(root) + + +# ───────────────────────────────────────────── +# Exemple d'utilisation +# ───────────────────────────────────────────── +if __name__ == "__main__": + + invoice = Invoice( + number="FA-2024-00123", + issue_date=date(2024, 3, 15), + due_date=date(2024, 4, 14), + currency="EUR", + type_code="380", + note="Facture relative au contrat n°CTR-2024-01", + + seller=Party( + name="Ma Société SAS", + vat_id="FR12345678901", + siret="12345678900012", + address=Address( + line1="10 rue de la Paix", + city="Paris", + postcode="75001", + country_code="FR", + ), + ), + + buyer=Party( + name="Client SARL", + vat_id="FR98765432100", + siret="98765432100011", + address=Address( + line1="5 avenue des Fleurs", + city="Lyon", + postcode="69001", + country_code="FR", + ), + ), + + lines=[ + InvoiceLine( + line_id="1", + description="Développement logiciel - Sprint 1", + quantity=10, + unit_code="HUR", # heures + unit_price=150.00, + vat_rate=0.0, + ), + InvoiceLine( + line_id="2", + description="Licence logicielle annuelle", + quantity=1, + unit_code="C62", # unité + unit_price=500.00, + vat_rate=0.0, + ), + InvoiceLine( + line_id="3", + description="Formation utilisateurs", + quantity=2, + unit_code="HUR", + unit_price=200.00, + vat_rate=0.0, + ), + ], + + payment_means_code="30", # 30 = virement bancaire + iban="FR7612345678901234567890189", + bic="BNPAFRPPXXX", + payment_reference="CTR-2024-01", + ) + + tree = build_facturx(invoice) + output_file = "factur-x.xml" + tree.write(output_file, xml_declaration=True, encoding="UTF-8", pretty_print=True) + + print(f"✅ Fichier généré : {output_file}") + print(f" Facture : {invoice.number}") + print(f" Émetteur : {invoice.seller.name}") + print(f" Destinataire : {invoice.buyer.name}") + + # Calcul récapitulatif + total_ht = sum(round(l.quantity * l.unit_price, 2) for l in invoice.lines) + total_tva = round(total_ht * 0.20, 2) + print(f" Total HT : {total_ht:.2f} €") + print(f" TVA 20% : {total_tva:.2f} €") + print(f" Total TTC : {total_ht + total_tva:.2f} €") \ No newline at end of file diff --git a/routers/factures.py b/routers/factures.py index ca010f9..0a5e89c 100644 --- a/routers/factures.py +++ b/routers/factures.py @@ -11,6 +11,7 @@ from numerotation import generer_numero_facture from config import settings from auth import get_current_user from template_helper import render +from generate_facturx_basic import Address, Party, Invoice, InvoiceLine, build_facturx router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)]) templates = Jinja2Templates(directory="templates") @@ -171,6 +172,11 @@ def telecharger_pdf(facture_id: int, db: Session = Depends(get_db)): pdf_bytes = HTML(string=html_content, base_url=".").write_pdf() filename = f"facture-{facture.numero}.pdf" + + tree = build_facturx(generate_xml (facture_id, db)) + output_file = "factur-x.xml" + tree.write(output_file, xml_declaration=True, encoding="UTF-8", pretty_print=True) + return Response( content=pdf_bytes, media_type="application/pdf", @@ -187,3 +193,57 @@ def apercu_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)) "facture": facture, "settings": settings, }) + +def generate_xml(facture_id: int, db: Session): + facture = db.query(Facture).get(facture_id) + invoice = Invoice( + number=facture.numero, + issue_date=facture.date_emission, + due_date=facture.date_echeance, + currency="EUR", + type_code="380", + note=("Facture établie suite au devis " + facture.devis_origine.numero), + + seller=Party( + name=settings.asso_nom, + #vat_id="FR12345678901", + siret=settings.asso_siret, + address=Address( + line1=settings.asso_adresse, + city=settings.asso_ville, + postcode=settings.asso_code_postal, + country_code="FR", + ), + ), + + buyer=Party( + name=facture.client.nom, + #vat_id="FR98765432100", + siret=facture.client.siret, + address=Address( + line1=facture.client.adresse, + city=facture.client.ville, + postcode=facture.client.code_postal, + country_code="FR", + ), + ), + + + + payment_means_code="30", # 30 = virement bancaire + iban=settings.asso_iban, + bic=settings.asso_bic, + payment_reference=facture.devis_origine.numero, + ) + + for l in facture.lignes: + invoice.lines.append(InvoiceLine( + line_id=l.id, + description=l.description, + quantity=l.quantite, + unit_code="C62", + unit_price=l.prix_unitaire_ht, + vat_rate=0.0, + )) + + return invoice -- 2.49.1 From b1029d10d37fa23296516e19bea60a04dc1665bd Mon Sep 17 00:00:00 2001 From: JbLb Date: Mon, 16 Mar 2026 01:32:28 +0100 Subject: [PATCH 2/9] generayion du XML a partir d'un template --- generate_facturx_basic.py | 345 ---------------------------------- routers/factures.py | 38 ++-- template_helper.py | 19 +- templates/factures/liste.html | 1 + 4 files changed, 45 insertions(+), 358 deletions(-) delete mode 100644 generate_facturx_basic.py diff --git a/generate_facturx_basic.py b/generate_facturx_basic.py deleted file mode 100644 index 7d78512..0000000 --- a/generate_facturx_basic.py +++ /dev/null @@ -1,345 +0,0 @@ -""" -Usage -pip install lxml -python generate_facturx_basic.py -""" - -""" -Générateur de XML Factur-X - Profil BASIC -Conforme à la norme EN 16931, syntaxe CII (Cross Industry Invoice) -Factur-X 1.0 / ZUGFeRD 2.x -""" - -from lxml import etree -from datetime import date -from dataclasses import dataclass, field -from typing import List, Optional - - -# ───────────────────────────────────────────── -# Namespaces CII obligatoires -# ───────────────────────────────────────────── -NSMAP = { - "rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100", - "ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100", - "udt": "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100", - "qdt": "urn:un:unece:uncefact:data:standard:QualifiedDataType:100", - "xsi": "http://www.w3.org/2001/XMLSchema-instance", -} -RSM = NSMAP["rsm"] -RAM = NSMAP["ram"] -UDT = NSMAP["udt"] - -PROFILE_BASIC = "urn:factur-x.eu:1p0:basic" - - -# ───────────────────────────────────────────── -# Structures de données -# ───────────────────────────────────────────── -@dataclass -class Address: - line1: str - city: str - postcode: str - country_code: str # ISO 3166-1 alpha-2, ex: "FR" - line2: Optional[str] = None - - -@dataclass -class Party: - name: str - vat_id: Optional[str] = None # ex: "FR12345678901" - siret: Optional[str] = None # schemeID="0002" - address: Optional[Address] = None - - -@dataclass -class TaxLine: - base_amount: float - rate: float # ex: 20.0 pour 20% - amount: float - category: str = "E" # S=standard, Z=zéro, E=exonéré, AE=autoliquidation - - -@dataclass -class InvoiceLine: - line_id: str - description: str - quantity: float - unit_code: str # ex: "C62"=unité, "HUR"=heure, "KGM"=kg - unit_price: float # prix HT unitaire - vat_rate: float # ex: 20.0 - vat_category: str = "S" - # Le montant HT de la ligne est calculé automatiquement - - -@dataclass -class Invoice: - # En-tête - number: str - issue_date: date - due_date: Optional[date] - currency: str # ex: "EUR" - type_code: str = "380" # 380=facture, 381=avoir, 384=facture rectificative - - # Parties - seller: Party = None - buyer: Party = None - - # Lignes - lines: List[InvoiceLine] = field(default_factory=list) - - # Paiement - payment_means_code: str = "30" # 30=virement - iban: Optional[str] = None - bic: Optional[str] = None - payment_reference: Optional[str] = None - - # Note (champ libre) - note: Optional[str] = None - - -# ───────────────────────────────────────────── -# Helpers XML -# ───────────────────────────────────────────── -def sub(parent, ns, tag, text=None, **attribs): - el = etree.SubElement(parent, f"{{{ns}}}{tag}", **attribs) - if text is not None: - el.text = str(text) - return el - - -def date_str(d: date) -> str: - return d.strftime("%Y%m%d") - - -def amount_str(v: float) -> str: - return f"{v:.2f}" - - -# ───────────────────────────────────────────── -# Construction du XML -# ───────────────────────────────────────────── -def build_facturx(inv: Invoice) -> etree._ElementTree: - - root = etree.Element(f"{{{RSM}}}CrossIndustryInvoice", nsmap=NSMAP) - - # ── 1. ExchangedDocumentContext ────────────── - ctx = sub(root, RSM, "ExchangedDocumentContext") - gp = sub(ctx, RAM, "GuidelineSpecifiedDocumentContextParameter") - sub(gp, RAM, "ID", PROFILE_BASIC) - - # ── 2. ExchangedDocument ──────────────────── - doc = sub(root, RSM, "ExchangedDocument") - sub(doc, RAM, "ID", inv.number) - sub(doc, RAM, "TypeCode", inv.type_code) - idt = sub(doc, RAM, "IssueDateTime") - sub(idt, UDT, "DateTimeString", date_str(inv.issue_date), format="102") - if inv.note: - sub(doc, RAM, "IncludedNote").append( - etree.fromstring(f'{inv.note}') - ) - - # ── 3. SupplyChainTradeTransaction ────────── - tx = sub(root, RSM, "SupplyChainTradeTransaction") - - # ── 3a. Lignes de facture (BASIC = lignes obligatoires) ── - for ln in inv.lines: - net = round(ln.quantity * ln.unit_price, 2) - ili = sub(tx, RAM, "IncludedSupplyChainTradeLineItem") - assoc = sub(ili, RAM, "AssociatedDocumentLineDocument") - sub(assoc, RAM, "LineID", ln.line_id) - - prod = sub(ili, RAM, "SpecifiedTradeProduct") - sub(prod, RAM, "Name", ln.description) - - la = sub(ili, RAM, "SpecifiedLineTradeAgreement") - gpp = sub(la, RAM, "GrossPriceProductTradePrice") - sub(gpp, RAM, "ChargeAmount", amount_str(ln.unit_price), currencyID=inv.currency) - npp = sub(la, RAM, "NetPriceProductTradePrice") - sub(npp, RAM, "ChargeAmount", amount_str(ln.unit_price), currencyID=inv.currency) - - ld = sub(ili, RAM, "SpecifiedLineTradeDelivery") - sub(ld, RAM, "BilledQuantity", amount_str(ln.quantity), unitCode=ln.unit_code) - - ls = sub(ili, RAM, "SpecifiedLineTradeSettlement") - tax = sub(ls, RAM, "ApplicableTradeTax") - sub(tax, RAM, "TypeCode", "VAT") - sub(tax, RAM, "CategoryCode", ln.vat_category) - sub(tax, RAM, "RateApplicablePercent", amount_str(ln.vat_rate)) - - smls = sub(ls, RAM, "SpecifiedTradeSettlementLineMonetarySummation") - sub(smls, RAM, "LineTotalAmount", amount_str(net), currencyID=inv.currency) - - # ── 3b. ApplicableHeaderTradeAgreement ────── - agr = sub(tx, RAM, "ApplicableHeaderTradeAgreement") - - def _party(parent_el, tag, party: Party): - p = sub(parent_el, RAM, tag) - sub(p, RAM, "Name", party.name) - if party.siret: - sr = sub(p, RAM, "SpecifiedLegalOrganization") - sub(sr, RAM, "ID", party.siret, schemeID="0002") - if party.vat_id: - tr = sub(p, RAM, "SpecifiedTaxRegistration") - sub(tr, RAM, "ID", party.vat_id, schemeID="VA") - if party.address: - addr = sub(p, RAM, "PostalTradeAddress") - sub(addr, RAM, "PostcodeCode", party.address.postcode) - sub(addr, RAM, "LineOne", party.address.line1) - if party.address.line2: - sub(addr, RAM, "LineTwo", party.address.line2) - sub(addr, RAM, "CityName", party.address.city) - sub(addr, RAM, "CountryID", party.address.country_code) - return p - - _party(agr, "SellerTradeParty", inv.seller) - _party(agr, "BuyerTradeParty", inv.buyer) - - if inv.payment_reference: - bor = sub(agr, RAM, "BuyerOrderReferencedDocument") - sub(bor, RAM, "IssuerAssignedID", inv.payment_reference) - - # ── 3c. ApplicableHeaderTradeDelivery ─────── - sub(tx, RAM, "ApplicableHeaderTradeDelivery") - - # ── 3d. ApplicableHeaderTradeSettlement ───── - stl = sub(tx, RAM, "ApplicableHeaderTradeSettlement") - sub(stl, RAM, "InvoiceCurrencyCode", inv.currency) - - # Moyens de paiement - pm = sub(stl, RAM, "SpecifiedTradeSettlementPaymentMeans") - sub(pm, RAM, "TypeCode", inv.payment_means_code) - if inv.iban: - acc = sub(pm, RAM, "PayeePartyCreditorFinancialAccount") - sub(acc, RAM, "IBANID", inv.iban) - if inv.bic: - fi = sub(pm, RAM, "PayeeSpecifiedCreditorFinancialInstitution") - sub(fi, RAM, "BICID", inv.bic) - - # TVA par taux (calcul à partir des lignes) - tax_totals: dict[tuple, list] = {} - for ln in inv.lines: - key = (ln.vat_rate, ln.vat_category) - tax_totals.setdefault(key, []).append(round(ln.quantity * ln.unit_price, 2)) - - total_ht = 0.0 - total_tva = 0.0 - for (rate, cat), bases in tax_totals.items(): - base = round(sum(bases), 2) - tva = round(base * rate / 100, 2) - total_ht += base - total_tva += tva - tax = sub(stl, RAM, "ApplicableTradeTax") - sub(tax, RAM, "CalculatedAmount", amount_str(tva), currencyID=inv.currency) - sub(tax, RAM, "TypeCode", "VAT") - sub(tax, RAM, "BasisAmount", amount_str(base), currencyID=inv.currency) - sub(tax, RAM, "CategoryCode", cat) - sub(tax, RAM, "RateApplicablePercent", amount_str(rate)) - - total_ht = round(total_ht, 2) - total_tva = round(total_tva, 2) - total_ttc = round(total_ht + total_tva, 2) - - # Échéance - if inv.due_date: - stp = sub(stl, RAM, "SpecifiedTradePaymentTerms") - ddt = sub(stp, RAM, "DueDateDateTime") - sub(ddt, UDT, "DateTimeString", date_str(inv.due_date), format="102") - - # Totaux - sums = sub(stl, RAM, "SpecifiedTradeSettlementHeaderMonetarySummation") - sub(sums, RAM, "LineTotalAmount", amount_str(total_ht), currencyID=inv.currency) - sub(sums, RAM, "TaxBasisTotalAmount",amount_str(total_ht), currencyID=inv.currency) - sub(sums, RAM, "TaxTotalAmount", amount_str(total_tva), currencyID=inv.currency) - sub(sums, RAM, "GrandTotalAmount", amount_str(total_ttc), currencyID=inv.currency) - sub(sums, RAM, "DuePayableAmount", amount_str(total_ttc), currencyID=inv.currency) - - return etree.ElementTree(root) - - -# ───────────────────────────────────────────── -# Exemple d'utilisation -# ───────────────────────────────────────────── -if __name__ == "__main__": - - invoice = Invoice( - number="FA-2024-00123", - issue_date=date(2024, 3, 15), - due_date=date(2024, 4, 14), - currency="EUR", - type_code="380", - note="Facture relative au contrat n°CTR-2024-01", - - seller=Party( - name="Ma Société SAS", - vat_id="FR12345678901", - siret="12345678900012", - address=Address( - line1="10 rue de la Paix", - city="Paris", - postcode="75001", - country_code="FR", - ), - ), - - buyer=Party( - name="Client SARL", - vat_id="FR98765432100", - siret="98765432100011", - address=Address( - line1="5 avenue des Fleurs", - city="Lyon", - postcode="69001", - country_code="FR", - ), - ), - - lines=[ - InvoiceLine( - line_id="1", - description="Développement logiciel - Sprint 1", - quantity=10, - unit_code="HUR", # heures - unit_price=150.00, - vat_rate=0.0, - ), - InvoiceLine( - line_id="2", - description="Licence logicielle annuelle", - quantity=1, - unit_code="C62", # unité - unit_price=500.00, - vat_rate=0.0, - ), - InvoiceLine( - line_id="3", - description="Formation utilisateurs", - quantity=2, - unit_code="HUR", - unit_price=200.00, - vat_rate=0.0, - ), - ], - - payment_means_code="30", # 30 = virement bancaire - iban="FR7612345678901234567890189", - bic="BNPAFRPPXXX", - payment_reference="CTR-2024-01", - ) - - tree = build_facturx(invoice) - output_file = "factur-x.xml" - tree.write(output_file, xml_declaration=True, encoding="UTF-8", pretty_print=True) - - print(f"✅ Fichier généré : {output_file}") - print(f" Facture : {invoice.number}") - print(f" Émetteur : {invoice.seller.name}") - print(f" Destinataire : {invoice.buyer.name}") - - # Calcul récapitulatif - total_ht = sum(round(l.quantity * l.unit_price, 2) for l in invoice.lines) - total_tva = round(total_ht * 0.20, 2) - print(f" Total HT : {total_ht:.2f} €") - print(f" TVA 20% : {total_tva:.2f} €") - print(f" Total TTC : {total_ht + total_tva:.2f} €") \ No newline at end of file diff --git a/routers/factures.py b/routers/factures.py index 0a5e89c..94099e4 100644 --- a/routers/factures.py +++ b/routers/factures.py @@ -10,8 +10,8 @@ from models import Facture, LigneFacture, Client, StatutFacture from numerotation import generer_numero_facture from config import settings from auth import get_current_user -from template_helper import render -from generate_facturx_basic import Address, Party, Invoice, InvoiceLine, build_facturx +from template_helper import render, render_xml +from generate_facturx_jinja2 import Address, Party, Invoice, InvoiceLine, generate_facturx_xml router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)]) templates = Jinja2Templates(directory="templates") @@ -156,9 +156,29 @@ def changer_statut_facture( db.commit() return RedirectResponse(f"/factures/{facture_id}", status_code=303) +from generate_facturx_jinja2 import Invoice, Party, Address, InvoiceLine, filter_amount, filter_datefmt -@router.get("/{facture_id}/pdf") -def telecharger_pdf(facture_id: int, db: Session = Depends(get_db)): +@router.get("/{facture_id}/facturx") +def telecharger_facturx(request: Request, facture_id: int, db: Session = Depends(get_db)): + facture = get_invoice_data(facture_id, db) # votre appel BDD + + # facture = db.query(Facture).get(facture_id) + if not facture: + raise HTTPException(status_code=404) + + xml_bytes = generate_facturx_xml(facture) + + filename=f"factur-x-{facture_id}.xml" + + return Response( + content= xml_bytes, + media_type="application/xml", + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) + + +@router.get("/{facture_id}/pdf", response_class=HTMLResponse) +def telecharger_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)): from weasyprint import HTML facture = db.query(Facture).get(facture_id) if not facture: @@ -173,10 +193,6 @@ def telecharger_pdf(facture_id: int, db: Session = Depends(get_db)): filename = f"facture-{facture.numero}.pdf" - tree = build_facturx(generate_xml (facture_id, db)) - output_file = "factur-x.xml" - tree.write(output_file, xml_declaration=True, encoding="UTF-8", pretty_print=True) - return Response( content=pdf_bytes, media_type="application/pdf", @@ -194,7 +210,7 @@ def apercu_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)) "settings": settings, }) -def generate_xml(facture_id: int, db: Session): +def get_invoice_data(facture_id: int, db: Session): facture = db.query(Facture).get(facture_id) invoice = Invoice( number=facture.numero, @@ -228,11 +244,8 @@ def generate_xml(facture_id: int, db: Session): ), ), - - payment_means_code="30", # 30 = virement bancaire iban=settings.asso_iban, - bic=settings.asso_bic, payment_reference=facture.devis_origine.numero, ) @@ -243,6 +256,7 @@ def generate_xml(facture_id: int, db: Session): quantity=l.quantite, unit_code="C62", unit_price=l.prix_unitaire_ht, + vat_category="E", vat_rate=0.0, )) diff --git a/template_helper.py b/template_helper.py index 3dd3c16..7330321 100644 --- a/template_helper.py +++ b/template_helper.py @@ -3,7 +3,7 @@ Helper pour injecter automatiquement current_user dans le contexte Jinja2. """ from fastapi import Request from fastapi.templating import Jinja2Templates -from starlette.responses import HTMLResponse +from starlette.responses import HTMLResponse, Response def render(templates: Jinja2Templates, template_name: str, @@ -17,3 +17,20 @@ def render(templates: Jinja2Templates, template_name: str, response = templates.TemplateResponse(template_name, ctx) response.status_code = status_code return response + +def render_xml(templates: Jinja2Templates, template_name: str, + request: Request, invoice: "Invoice", + filename: str = "factur-x.xml") -> Response: + + xml_content = templates.env.get_template(template_name).render( + request=request, + invoice=invoice, + seller=invoice.seller, + buyer=invoice.buyer, + ) + + return Response( + content=xml_content, + media_type="application/xml", + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) diff --git a/templates/factures/liste.html b/templates/factures/liste.html index 99ed8bf..355891b 100644 --- a/templates/factures/liste.html +++ b/templates/factures/liste.html @@ -29,6 +29,7 @@ Voir PDF + facturx {% endfor %} -- 2.49.1 From bc82603f96a4c502ac20da2e02d6b3a0dce1bda5 Mon Sep 17 00:00:00 2001 From: JbLb Date: Mon, 16 Mar 2026 01:34:40 +0100 Subject: [PATCH 3/9] =?UTF-8?q?g=C3=A9n=C3=A9ration=20XML=20et=20emplate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- generate_facturx_jinja2.py | 225 +++++++++++++++++++++++ templates/xml/factur-x-basic.xml.j2 | 176 ++++++++++++++++++ templates/xml/factur-x-basic.xml.j2.back | 180 ++++++++++++++++++ 3 files changed, 581 insertions(+) create mode 100644 generate_facturx_jinja2.py create mode 100644 templates/xml/factur-x-basic.xml.j2 create mode 100644 templates/xml/factur-x-basic.xml.j2.back diff --git a/generate_facturx_jinja2.py b/generate_facturx_jinja2.py new file mode 100644 index 0000000..fd34e18 --- /dev/null +++ b/generate_facturx_jinja2.py @@ -0,0 +1,225 @@ +""" +Générateur Factur-X BASIC via template Jinja2 +""" + +from jinja2 import Environment, FileSystemLoader +from dataclasses import dataclass, field +from typing import List, Optional +from datetime import date +from collections import defaultdict + +from fastapi.templating import Jinja2Templates + +templates = Jinja2Templates(directory="templates") + +# ───────────────────────────────────────────── +# Structures de données (identiques au générateur lxml) +# ───────────────────────────────────────────── +@dataclass +class Address: + line1: str + city: str + postcode: str + country_code: str + line2: Optional[str] = None + + +@dataclass +class Party: + name: str + vat_id: Optional[str] = None + siret: Optional[str] = None + address: Optional[Address] = None + + +@dataclass +class InvoiceLine: + line_id: str + description: str + quantity: float + unit_code: str + unit_price: float + vat_rate: float + vat_category: str = "S" + +# Raison d'exonération par catégorie TVA +EXEMPTION_REASONS = { + "E": "TVA non applicable, art. 293 B du CGI", + "Z": "Taux zéro", + "AE": "Autoliquidation", + "K": "Exonération intracommunautaire", + "G": "Exportation hors UE", +} + +@dataclass +class TaxLine: + rate: float + category: str + base_amount: float + amount: float + exemption_reason: Optional[str] = None + +@dataclass +class Invoice: + number: str + issue_date: date + currency: str + seller: Party + buyer: Party + lines: List[InvoiceLine] = field(default_factory=list) + due_date: Optional[date] = None + type_code: str = "380" + payment_means_code: str = "30" + iban: Optional[str] = None + payment_reference: Optional[str] = None + note: Optional[str] = None + + # Calculés automatiquement (voir compute()) + tax_lines: List[TaxLine] = field(default_factory=list) + total_ht: float = 0.0 + total_tva: float = 0.0 + total_ttc: float = 0.0 + + def compute(self): + """Calcule les totaux et les lignes de TVA depuis les lignes de facture.""" + tax_groups = defaultdict(float) + self.total_ht = 0.0 + + for ln in self.lines: + net = round(ln.quantity * ln.unit_price, 2) + self.total_ht += net + tax_groups[(ln.vat_rate, ln.vat_category)] += net + + self.total_ht = round(self.total_ht, 2) + self.tax_lines = [] + self.total_tva = 0.0 + + for (rate, category), base in tax_groups.items(): + base = round(base, 2) + amount = round(base * rate / 100, 2) + self.total_tva += amount + self.tax_lines.append(TaxLine( + rate=rate, + category=category, + base_amount=base, + amount=amount, + # Raison d'exonération automatique si catégorie != "S" (standard) + exemption_reason=EXEMPTION_REASONS.get(category) if category != "S" else None, + )) + + self.total_tva = round(self.total_tva, 2) + self.total_ttc = round(self.total_ht + self.total_tva, 2) + + +# ───────────────────────────────────────────── +# Filtres Jinja2 personnalisés +# ───────────────────────────────────────────── +def filter_amount(value: float) -> str: + return f"{float(value):.2f}" + + +def filter_datefmt(value: date) -> str: + return value.strftime("%Y%m%d") + + +# ───────────────────────────────────────────── +# Générateur +# ───────────────────────────────────────────── +def generate_facturx_xml(invoice: Invoice, template_dir: str = ".", output_file: str = "factur-x.xml") -> str: + """ + Génère le XML Factur-X BASIC depuis le template Jinja2. + Retourne le XML sous forme de chaîne et l'écrit dans output_file. + """ + # Calcul des totaux + invoice.compute() + + # Environnement Jinja2 + env = Environment( + loader=FileSystemLoader(template_dir), + autoescape=False, # XML géré manuellement + trim_blocks=True, # supprime le \n après les blocs {% %} + lstrip_blocks=True, # supprime les espaces avant {% %} + ) + env.filters["amount"] = filter_amount + env.filters["datefmt"] = filter_datefmt + + template = env.get_template("templates/xml/factur-x-basic.xml.j2") + xml_str = template.render( + invoice=invoice, + seller=invoice.seller, + buyer=invoice.buyer, + ) + + with open(output_file, "w", encoding="utf-8") as f: + f.write(xml_str) + + return xml_str + + +# ───────────────────────────────────────────── +# Exemple d'utilisation +# ───────────────────────────────────────────── +if __name__ == "__main__": + + invoice = Invoice( + number="FA-2024-00123", + issue_date=date(2024, 3, 15), + due_date=date(2024, 4, 14), + currency="EUR", + type_code="380", + note="Facture relative au contrat n°CTR-2024-01", + + seller=Party( + name="Ma Société SAS", + # vat_id absent : pas de numéro TVA en franchise (art. 293 B CGI) + siret="12345678900012", + address=Address( + line1="10 rue de la Paix", + city="Paris", + postcode="75001", + country_code="FR", + ), + ), + + buyer=Party( + name="Client SARL", + vat_id="FR98765432100", + siret="98765432100011", + address=Address( + line1="5 avenue des Fleurs", + city="Lyon", + postcode="69001", + country_code="FR", + ), + ), + + payment_means_code="30", + iban="FR7612345678901234567890189", + payment_reference="CTR-2024-01", + ) + + # Ajout des lignes — franchise de TVA (art. 293 B du CGI) + # Utiliser vat_category="E" et vat_rate=0.0 + articles = [ + {"desc": "Développement logiciel - Sprint 1", "qty": 10, "unit": "HUR", "prix": 150.0, "tva": 0.0, "cat": "E"}, + {"desc": "Licence logicielle annuelle", "qty": 1, "unit": "C62", "prix": 500.0, "tva": 0.0, "cat": "E"}, + {"desc": "Formation utilisateurs", "qty": 2, "unit": "HUR", "prix": 200.0, "tva": 0.0, "cat": "E"}, + ] + + for i, art in enumerate(articles, start=1): + invoice.lines.append(InvoiceLine( + line_id=str(i), + description=art["desc"], + quantity=art["qty"], + unit_code=art["unit"], + unit_price=art["prix"], + vat_rate=art["tva"], + vat_category=art["cat"], # "E" = exonéré (franchise TVA) + )) + + xml = generate_facturx_xml(invoice, template_dir=".", output_file="factur-x.xml") + + print(f"✅ Fichier généré : factur-x.xml") + print(f" Total HT : {invoice.total_ht:.2f} €") + print(f" TVA 20% : {invoice.total_tva:.2f} €") + print(f" Total TTC : {invoice.total_ttc:.2f} €") diff --git a/templates/xml/factur-x-basic.xml.j2 b/templates/xml/factur-x-basic.xml.j2 new file mode 100644 index 0000000..72bdea2 --- /dev/null +++ b/templates/xml/factur-x-basic.xml.j2 @@ -0,0 +1,176 @@ + + + + + + + urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic + + + + + + {{ invoice.number }} + {{ invoice.type_code }} + + {{ invoice.issue_date | datefmt }} + + {% if invoice.note %} + + {{ invoice.note }} + + {% endif %} + + + + + + + {% for line in invoice.lines %} + + + {{ line.line_id }} + + + {{ line.description }} + + + + {{ line.unit_price | amount }} + + + + {{ line.quantity | amount }} + + + + VAT + {{ line.vat_category }} + {{ line.vat_rate | amount }} + + + {{ (line.quantity * line.unit_price) | amount }} + + + + {% endfor %} + + + + + + {{ seller.name }} + {% if seller.siret %} + + {{ seller.siret }} + + {% endif %} + {% if seller.address %} + + {{ seller.address.postcode }} + {{ seller.address.line1 }} + {% if seller.address.line2 %} + {{ seller.address.line2 }} + {% endif %} + {{ seller.address.city }} + {{ seller.address.country_code }} + + {% endif %} + {% if seller.vat_id %} + + {{ seller.vat_id }} + + {% elif seller.siret %} + {# Franchise TVA (art. 293 B CGI) : pas de n° TVA, on utilise le SIRET avec schemeID="FC" #} + + {{ seller.siret }} + + {% endif %} + + + + {{ buyer.name }} + {% if buyer.siret %} + + {{ buyer.siret }} + + {% endif %} + {% if buyer.address %} + + {{ buyer.address.postcode }} + {{ buyer.address.line1 }} + {% if buyer.address.line2 %} + {{ buyer.address.line2 }} + {% endif %} + {{ buyer.address.city }} + {{ buyer.address.country_code }} + + {% endif %} + + + {% if invoice.payment_reference %} + + {{ invoice.payment_reference }} + + {% endif %} + + + + + + + {{ buyer.name }} + + + + + + {{ invoice.currency }} + + + {{ invoice.payment_means_code }} + {% if invoice.iban %} + + {{ invoice.iban }} + + {% endif %} + + + {% for tax in invoice.tax_lines %} + + {{ tax.amount | amount }} + VAT + {% if tax.exemption_reason %} + {{ tax.exemption_reason }} + {% endif %} + {{ tax.base_amount | amount }} + {{ tax.category }} + {{ tax.rate | amount }} + + {% endfor %} + + {% if invoice.due_date %} + + + {{ invoice.due_date | datefmt }} + + + {% endif %} + + + {{ invoice.total_ht | amount }} + {{ invoice.total_ht | amount }} + {{ invoice.total_tva | amount }} + {{ invoice.total_ttc | amount }} + {{ invoice.total_ttc | amount }} + + + + + + diff --git a/templates/xml/factur-x-basic.xml.j2.back b/templates/xml/factur-x-basic.xml.j2.back new file mode 100644 index 0000000..654bb02 --- /dev/null +++ b/templates/xml/factur-x-basic.xml.j2.back @@ -0,0 +1,180 @@ + + + + + + + urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic + + + + + + {{ invoice.number }} + {{ invoice.type_code }} + + {{ invoice.issue_date | datefmt }} + + {% if invoice.note %} + + {{ invoice.note }} + + {% endif %} + + + + + + + {% for line in invoice.lines %} + + + {{ line.line_id }} + + + {{ line.description }} + + + + {{ line.unit_price | amount }} + + + + {{ line.quantity | amount }} + + + + VAT + {{ line.vat_category }} + {{ line.vat_rate | amount }} + + + {{ (line.quantity * line.unit_price) | amount }} + + + + {% endfor %} + + + + + + {{ seller.name }} + {% if seller.siret %} + + {{ seller.siret }} + + {% endif %} + {% if seller.address %} + + {{ seller.address.postcode }} + {{ seller.address.line1 }} + {% if seller.address.line2 %} + {{ seller.address.line2 }} + {% endif %} + {{ seller.address.city }} + {{ seller.address.country_code }} + + {% endif %} + {% if seller.vat_id %} + + {{ seller.vat_id }} + + {% endif %} + + + + {{ buyer.name }} + {% if buyer.siret %} + + {{ buyer.siret }} + + {% endif %} + {% if buyer.address %} + + {{ buyer.address.postcode }} + {{ buyer.address.line1 }} + {% if buyer.address.line2 %} + {{ buyer.address.line2 }} + {% endif %} + {{ buyer.address.city }} + {{ buyer.address.country_code }} + + {% endif %} + + + {% if invoice.payment_reference %} + + {{ invoice.payment_reference }} + + {% endif %} + + + + + + + {{ buyer.name }} + + + + + + {{ invoice.currency }} + + + {{ invoice.payment_means_code }} + {% if invoice.iban %} + + {{ invoice.iban }} + + {% endif %} + + + {% for tax in invoice.tax_lines %} + + {{ tax.amount | amount }} + VAT + {% if tax.exemption_reason %} + {{ tax.exemption_reason }} + {% endif %} + {{ tax.base_amount | amount }} + {{ tax.category }} + {{ tax.rate | amount }} + + + + + {{ tax.amount | amount }} + VAT + {{ tax.base_amount | amount }} + {{ tax.category }} + {{ tax.rate | amount }} + + {% endfor %} + + {% if invoice.due_date %} + + + {{ invoice.due_date | datefmt }} + + + {% endif %} + + + {{ invoice.total_ht | amount }} + {{ invoice.total_ht | amount }} + {{ invoice.total_tva | amount }} + {{ invoice.total_ttc | amount }} + {{ invoice.total_ttc | amount }} + + + + + + -- 2.49.1 From 40c05f7860c07e0d011e4dac237e53e90c821287 Mon Sep 17 00:00:00 2001 From: JbLb Date: Mon, 16 Mar 2026 01:35:39 +0100 Subject: [PATCH 4/9] remove unused file --- templates/xml/factur-x-basic.xml.j2.back | 180 ----------------------- 1 file changed, 180 deletions(-) delete mode 100644 templates/xml/factur-x-basic.xml.j2.back diff --git a/templates/xml/factur-x-basic.xml.j2.back b/templates/xml/factur-x-basic.xml.j2.back deleted file mode 100644 index 654bb02..0000000 --- a/templates/xml/factur-x-basic.xml.j2.back +++ /dev/null @@ -1,180 +0,0 @@ - - - - - - - urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic - - - - - - {{ invoice.number }} - {{ invoice.type_code }} - - {{ invoice.issue_date | datefmt }} - - {% if invoice.note %} - - {{ invoice.note }} - - {% endif %} - - - - - - - {% for line in invoice.lines %} - - - {{ line.line_id }} - - - {{ line.description }} - - - - {{ line.unit_price | amount }} - - - - {{ line.quantity | amount }} - - - - VAT - {{ line.vat_category }} - {{ line.vat_rate | amount }} - - - {{ (line.quantity * line.unit_price) | amount }} - - - - {% endfor %} - - - - - - {{ seller.name }} - {% if seller.siret %} - - {{ seller.siret }} - - {% endif %} - {% if seller.address %} - - {{ seller.address.postcode }} - {{ seller.address.line1 }} - {% if seller.address.line2 %} - {{ seller.address.line2 }} - {% endif %} - {{ seller.address.city }} - {{ seller.address.country_code }} - - {% endif %} - {% if seller.vat_id %} - - {{ seller.vat_id }} - - {% endif %} - - - - {{ buyer.name }} - {% if buyer.siret %} - - {{ buyer.siret }} - - {% endif %} - {% if buyer.address %} - - {{ buyer.address.postcode }} - {{ buyer.address.line1 }} - {% if buyer.address.line2 %} - {{ buyer.address.line2 }} - {% endif %} - {{ buyer.address.city }} - {{ buyer.address.country_code }} - - {% endif %} - - - {% if invoice.payment_reference %} - - {{ invoice.payment_reference }} - - {% endif %} - - - - - - - {{ buyer.name }} - - - - - - {{ invoice.currency }} - - - {{ invoice.payment_means_code }} - {% if invoice.iban %} - - {{ invoice.iban }} - - {% endif %} - - - {% for tax in invoice.tax_lines %} - - {{ tax.amount | amount }} - VAT - {% if tax.exemption_reason %} - {{ tax.exemption_reason }} - {% endif %} - {{ tax.base_amount | amount }} - {{ tax.category }} - {{ tax.rate | amount }} - - - - - {{ tax.amount | amount }} - VAT - {{ tax.base_amount | amount }} - {{ tax.category }} - {{ tax.rate | amount }} - - {% endfor %} - - {% if invoice.due_date %} - - - {{ invoice.due_date | datefmt }} - - - {% endif %} - - - {{ invoice.total_ht | amount }} - {{ invoice.total_ht | amount }} - {{ invoice.total_tva | amount }} - {{ invoice.total_ttc | amount }} - {{ invoice.total_ttc | amount }} - - - - - - -- 2.49.1 From b403efd0563cb20f19935a6df015b6e1688dc1e6 Mon Sep 17 00:00:00 2001 From: JbLb Date: Mon, 16 Mar 2026 17:47:16 +0100 Subject: [PATCH 5/9] Code cleanup --- routers/factures.py | 3 +-- template_helper.py | 17 ----------------- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/routers/factures.py b/routers/factures.py index 94099e4..6467464 100644 --- a/routers/factures.py +++ b/routers/factures.py @@ -10,7 +10,7 @@ from models import Facture, LigneFacture, Client, StatutFacture from numerotation import generer_numero_facture from config import settings from auth import get_current_user -from template_helper import render, render_xml +from template_helper import render from generate_facturx_jinja2 import Address, Party, Invoice, InvoiceLine, generate_facturx_xml router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)]) @@ -161,7 +161,6 @@ from generate_facturx_jinja2 import Invoice, Party, Address, InvoiceLine, filte @router.get("/{facture_id}/facturx") def telecharger_facturx(request: Request, facture_id: int, db: Session = Depends(get_db)): facture = get_invoice_data(facture_id, db) # votre appel BDD - # facture = db.query(Facture).get(facture_id) if not facture: raise HTTPException(status_code=404) diff --git a/template_helper.py b/template_helper.py index 7330321..cf6b8e2 100644 --- a/template_helper.py +++ b/template_helper.py @@ -17,20 +17,3 @@ def render(templates: Jinja2Templates, template_name: str, response = templates.TemplateResponse(template_name, ctx) response.status_code = status_code return response - -def render_xml(templates: Jinja2Templates, template_name: str, - request: Request, invoice: "Invoice", - filename: str = "factur-x.xml") -> Response: - - xml_content = templates.env.get_template(template_name).render( - request=request, - invoice=invoice, - seller=invoice.seller, - buyer=invoice.buyer, - ) - - return Response( - content=xml_content, - media_type="application/xml", - headers={"Content-Disposition": f'attachment; filename="{filename}"'} - ) -- 2.49.1 From bb12b3777f850ee36607fb1bb01d727a9b9cf081 Mon Sep 17 00:00:00 2001 From: JbLb Date: Mon, 16 Mar 2026 21:21:07 +0100 Subject: [PATCH 6/9] =?UTF-8?q?retrait=20des=20fonctions=20d'aide=20et=20f?= =?UTF-8?q?inalisation=20de=20la=20g=C3=A9n=C3=A9ration=20de=20fichier=20p?= =?UTF-8?q?df=20conforme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routers/factures.py | 24 +++++++----------------- templates/factures/liste.html | 1 - 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/routers/factures.py b/routers/factures.py index 6467464..80a210b 100644 --- a/routers/factures.py +++ b/routers/factures.py @@ -12,6 +12,7 @@ from config import settings from auth import get_current_user from template_helper import render from generate_facturx_jinja2 import Address, Party, Invoice, InvoiceLine, generate_facturx_xml +from facturx import generate_from_binary router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)]) templates = Jinja2Templates(directory="templates") @@ -158,23 +159,6 @@ def changer_statut_facture( from generate_facturx_jinja2 import Invoice, Party, Address, InvoiceLine, filter_amount, filter_datefmt -@router.get("/{facture_id}/facturx") -def telecharger_facturx(request: Request, facture_id: int, db: Session = Depends(get_db)): - facture = get_invoice_data(facture_id, db) # votre appel BDD - # facture = db.query(Facture).get(facture_id) - if not facture: - raise HTTPException(status_code=404) - - xml_bytes = generate_facturx_xml(facture) - - filename=f"factur-x-{facture_id}.xml" - - return Response( - content= xml_bytes, - media_type="application/xml", - headers={"Content-Disposition": f'attachment; filename="{filename}"'} - ) - @router.get("/{facture_id}/pdf", response_class=HTMLResponse) def telecharger_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)): @@ -192,6 +176,12 @@ def telecharger_pdf(request: Request, facture_id: int, db: Session = Depends(get filename = f"facture-{facture.numero}.pdf" + xml_bytes = generate_facturx_xml(get_invoice_data(facture_id, db)) + + pdf_bytes = generate_from_binary(pdf_bytes, xml_bytes) + + filename = f"facture-{facture.numero}.pdf" + return Response( content=pdf_bytes, media_type="application/pdf", diff --git a/templates/factures/liste.html b/templates/factures/liste.html index 355891b..99ed8bf 100644 --- a/templates/factures/liste.html +++ b/templates/factures/liste.html @@ -29,7 +29,6 @@ Voir PDF - facturx {% endfor %} -- 2.49.1 From f25cf026609bdc4fae32bc3feb1c5f14a7678f3b Mon Sep 17 00:00:00 2001 From: JbLb Date: Mon, 16 Mar 2026 23:16:23 +0100 Subject: [PATCH 7/9] =?UTF-8?q?correction=20pour=20conformit=C3=A9=20compl?= =?UTF-8?q?ete=20PDF/A-3=20cod=C3=A9=20avec=20l'aide=20de=20claude=20IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routers/factures.py | 57 ++++++++++++++++++++++++++++++-------- templates/pdf/facture.html | 37 +++++++++++++++++-------- 2 files changed, 72 insertions(+), 22 deletions(-) diff --git a/routers/factures.py b/routers/factures.py index 80a210b..9ed35ac 100644 --- a/routers/factures.py +++ b/routers/factures.py @@ -159,29 +159,64 @@ def changer_statut_facture( from generate_facturx_jinja2 import Invoice, Party, Address, InvoiceLine, filter_amount, filter_datefmt - -@router.get("/{facture_id}/pdf", response_class=HTMLResponse) +@router.get("/{facture_id}/pdf") def telecharger_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)): - from weasyprint import HTML + from weasyprint import HTML, Attachment + from facturx import generate_from_binary + import hashlib + import pikepdf + from pikepdf import Name + import io + facture = db.query(Facture).get(facture_id) if not facture: raise HTTPException(status_code=404) + # 1. XML Factur-X + invoice = get_invoice_data(facture_id, db) + xml_str = generate_facturx_xml(invoice) + xml_bytes = xml_str.encode("utf-8") + + # 2. Rendu HTML → PDF simple (pas PDF/A-3 ici) html_content = templates.get_template("pdf/facture.html").render({ "facture": facture, "settings": settings, }) + pdf_bytes = HTML(string=html_content, base_url=".").write_pdf( + pdf_variant="pdf/a-3b", + ) - pdf_bytes = HTML(string=html_content, base_url=".").write_pdf() + # 3. generate_from_binary gère : + # - l'intégration du XML + # - les métadonnées XMP Factur-X (DocumentType, DocumentFileName, etc.) + # - la conformité PDF/A-3b + pdf_bytes = generate_from_binary( + pdf_bytes, + xml_bytes, + flavor="factur-x", + level="BASIC", + ) + + # 4. Corriger uniquement /EF/F/Subtype → /text/xml avec pikepdf + pdf_io = io.BytesIO(pdf_bytes) + with pikepdf.open(pdf_io) as pdf: + names = pdf.Root.Names.EmbeddedFiles.Names + i = 0 + while i < len(names): + i += 1 # sauter la clé (string) + if i < len(names): + obj = names[i] + try: + obj.EF.F.Subtype = Name("/text/xml") + except Exception: + pass + i += 1 + + output = io.BytesIO() + pdf.save(output) + pdf_bytes = output.getvalue() filename = f"facture-{facture.numero}.pdf" - - xml_bytes = generate_facturx_xml(get_invoice_data(facture_id, db)) - - pdf_bytes = generate_from_binary(pdf_bytes, xml_bytes) - - filename = f"facture-{facture.numero}.pdf" - return Response( content=pdf_bytes, media_type="application/pdf", diff --git a/templates/pdf/facture.html b/templates/pdf/facture.html index 80a787c..dd81ce1 100644 --- a/templates/pdf/facture.html +++ b/templates/pdf/facture.html @@ -4,10 +4,22 @@ -- 2.49.1 From dbb5a298635fd9f948f9602f6a5760516e35606f Mon Sep 17 00:00:00 2001 From: JbLb Date: Mon, 16 Mar 2026 23:18:09 +0100 Subject: [PATCH 8/9] =?UTF-8?q?ajout=20des=20libs=20utilis=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index 5f509cc..db75e3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,5 @@ pydantic==2.9.2 pydantic-settings==2.5.2 bcrypt==4.2.0 itsdangerous==2.2.0 +factur-x==3.16 +pikepdf==10.5.0 -- 2.49.1 From 87e78314c65c90d6508c451ca64a8ecd7f0cbb87 Mon Sep 17 00:00:00 2001 From: JbLb Date: Mon, 16 Mar 2026 23:47:27 +0100 Subject: [PATCH 9/9] update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3df2c0c..8e18535 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Facturation Association -Application de facturation légale française pour associations (loi 1901), +Application de facturation légale française pour associations (loi 1901) ou micro-entrepreneur, non assujetties à la TVA (art. 293B du CGI). ## Fonctionnalités @@ -9,7 +9,7 @@ non assujetties à la TVA (art. 293B du CGI). - Devis avec numérotation automatique (DEV-AAAA-XXXX) - Factures avec numérotation chronologique (AAAA-XXXX) - Conversion devis → facture -- Génération PDF avec toutes les mentions légales françaises +- Génération PDF avec toutes les mentions légales françaises et conforme aux normes de facturation electronique Factur-X - Suivi des statuts (émise / payée / annulée) ## Stack -- 2.49.1