using System.Text; using iText.Kernel.Colors; using iText.Kernel.Pdf; using iText.Kernel.Pdf.Filespec; using iText.Layout; using iText.Layout.Element; using iText.Pdfa; using Microsoft.Extensions.Configuration; using s2industries.ZUGFeRD; namespace SIFinance.Shared; public record FacturXLigne(string Designation, decimal Quantite, decimal PrixUnitaireHt); public record FacturXPartie(string Nom, string? Rue, string? CodePostal, string? Ville, string PaysCode, string? NumeroTva); public record FacturXEmissionRequest( string NumeroFacture, DateOnly DateFacture, FacturXPartie Vendeur, FacturXPartie Acheteur, List Lignes, decimal TauxTva); // Moteur réutilisable d'émission de factures électroniques Factur-X/ZUGFeRD (norme // EN16931) : XML structuré via ZUGFeRD-csharp, assemblage PDF/A-3 + attachement du XML // via iText7. Vit dans SIFinance.Shared pour être appelable depuis n'importe quel // module (Achats, Sous-Traitants, futur Contrat Étudiant) une fois qu'un cas d'usage // concret d'émission est identifié — voir appels dans les endpoints des modules. // // Nécessite un profil colorimétrique ICC sRGB valide (obligatoire pour la conformité // PDF/A-3, "PdfOutputIntent") — fichier binaire non fourni ici (voir // SIFinance.Shared/Resources/README.md pour comment se le procurer et où le déposer). public class FacturXEmissionService(IConfiguration configuration) { private string CheminProfilIcc => configuration["FacturX:ProfilIccSrgb"] ?? Path.Combine(AppContext.BaseDirectory, "Resources", "sRGB.icc"); public byte[] Generer(FacturXEmissionRequest request) { if (request.Lignes.Count == 0) throw new ArgumentException("Une facture électronique doit contenir au moins une ligne.", nameof(request)); var montantHt = request.Lignes.Sum(l => Math.Round(l.Quantite * l.PrixUnitaireHt, 2)); var montantTva = Math.Round(montantHt * request.TauxTva / 100m, 2); var montantTtc = montantHt + montantTva; var xmlBytes = GenererXml(request, montantHt, montantTva, montantTtc); return AssemblerPdfA3(request, xmlBytes, montantHt, montantTva, montantTtc); } private static byte[] GenererXml(FacturXEmissionRequest request, decimal montantHt, decimal montantTva, decimal montantTtc) { var desc = InvoiceDescriptor.CreateInvoice(request.NumeroFacture, request.DateFacture.ToDateTime(TimeOnly.MinValue), CurrencyCodes.EUR); desc.SetSeller( request.Vendeur.Nom, request.Vendeur.CodePostal ?? "", request.Vendeur.Ville ?? "", request.Vendeur.Rue ?? "", ParsePays(request.Vendeur.PaysCode), request.Vendeur.NumeroTva); desc.SetBuyer( request.Acheteur.Nom, request.Acheteur.CodePostal ?? "", request.Acheteur.Ville ?? "", request.Acheteur.Rue ?? "", ParsePays(request.Acheteur.PaysCode), request.Acheteur.NumeroTva); foreach (var ligne in request.Lignes) { desc.AddTradeLineItem( name: ligne.Designation, netUnitPrice: ligne.PrixUnitaireHt, unitCode: QuantityCodes.C62, billedQuantity: ligne.Quantite, taxType: TaxTypes.VAT, categoryCode: TaxCategoryCodes.S, taxPercent: request.TauxTva); } desc.AddApplicableTradeTax(montantHt, request.TauxTva, montantTva, TaxTypes.VAT, TaxCategoryCodes.S); desc.SetTotals( lineTotalAmount: montantHt, taxBasisAmount: montantHt, taxTotalAmount: montantTva, grandTotalAmount: montantTtc, duePayableAmount: montantTtc); using var xmlStream = new MemoryStream(); desc.Save(xmlStream, ZUGFeRDVersion.Version23, Profile.Comfort); return xmlStream.ToArray(); } private byte[] AssemblerPdfA3(FacturXEmissionRequest request, byte[] xmlBytes, decimal montantHt, decimal montantTva, decimal montantTtc) { var cheminIcc = CheminProfilIcc; if (!File.Exists(cheminIcc)) throw new InvalidOperationException( $"Profil ICC sRGB introuvable ({cheminIcc}) — requis pour générer un PDF/A-3 conforme. " + "Voir SIFinance.Shared/Resources/README.md."); using var pdfMemStream = new MemoryStream(); using (var iccStream = File.OpenRead(cheminIcc)) { var outputIntent = new PdfOutputIntent("Custom", "", "http://www.color.org", "sRGB IEC61966-2.1", iccStream); using var writer = new PdfWriter(pdfMemStream); using var pdfDoc = new PdfADocument(writer, PdfAConformance.PDF_A_3B, outputIntent); pdfDoc.SetTagged(); using (var document = new Document(pdfDoc)) { document.Add(new Paragraph($"Facture {request.NumeroFacture}").SetFontSize(18).SimulateBold()); document.Add(new Paragraph($"Date : {request.DateFacture:dd/MM/yyyy}")); document.Add(new Paragraph(" ")); document.Add(new Paragraph("Émetteur").SimulateBold()); document.Add(new Paragraph($"{request.Vendeur.Nom}\n{request.Vendeur.Rue}\n{request.Vendeur.CodePostal} {request.Vendeur.Ville}")); document.Add(new Paragraph(" ")); document.Add(new Paragraph("Destinataire").SimulateBold()); document.Add(new Paragraph($"{request.Acheteur.Nom}\n{request.Acheteur.Rue}\n{request.Acheteur.CodePostal} {request.Acheteur.Ville}")); document.Add(new Paragraph(" ")); var table = new Table(4).UseAllAvailableWidth(); table.AddHeaderCell("Désignation"); table.AddHeaderCell("Qté"); table.AddHeaderCell("PU HT"); table.AddHeaderCell("Total HT"); foreach (var ligne in request.Lignes) { table.AddCell(ligne.Designation); table.AddCell(ligne.Quantite.ToString("N2")); table.AddCell($"{ligne.PrixUnitaireHt:N2} €"); table.AddCell($"{(ligne.Quantite * ligne.PrixUnitaireHt):N2} €"); } document.Add(table); document.Add(new Paragraph(" ")); document.Add(new Paragraph($"Total HT : {montantHt:N2} €")); document.Add(new Paragraph($"TVA ({request.TauxTva:N0}%) : {montantTva:N2} €")); document.Add(new Paragraph($"Total TTC : {montantTtc:N2} €").SimulateBold()); } var fileSpec = PdfFileSpec.CreateEmbeddedFileSpec( pdfDoc, xmlBytes, "Facture électronique Factur-X", "factur-x.xml", PdfName.ApplicationXml, null, PdfName.Data); pdfDoc.AddFileAttachment("factur-x.xml", fileSpec); } return pdfMemStream.ToArray(); } private static s2industries.ZUGFeRD.CountryCodes ParsePays(string code) => Enum.TryParse(code, true, out var parsed) ? parsed : s2industries.ZUGFeRD.CountryCodes.FR; }