Files
SIFINANCE/SIFinance.Shared/FacturXEmissionService.cs
T
2026-09-11 14:55:41 +02:00

146 lines
7.0 KiB
C#

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<FacturXLigne> 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<s2industries.ZUGFeRD.CountryCodes>(code, true, out var parsed) ? parsed : s2industries.ZUGFeRD.CountryCodes.FR;
}