146 lines
7.0 KiB
C#
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;
|
|
}
|