feat(pdf): redesign invoice PDF layout and structure

Refactored the invoice PDF generation to use a modular, visually
improved
layout. Extracted header, details, items, payments, summary, and notes
sections into dedicated functions for clarity and maintainability.
Introduced color constants and improved table formatting for better
readability. Updated labels and formatting to enhance the overall
appearance and user experience of generated invoices.
This commit is contained in:
Ruidy 2026-03-27 14:35:52 +01:00
parent 0f327c814a
commit 24f289b767
No known key found for this signature in database
GPG key ID: 705C24D202990805

View file

@ -15,76 +15,46 @@ import (
type HTMLPdfClient struct{}
const (
invoiceMarginX = 10.0
invoiceContentWidth = 190.0
invoiceHeaderHeight = 28.0
invoiceTableRowH = 8.5
invoiceSummaryLineH = 10.0
invoiceNotesPadding = 3.0
)
type rgbColor struct {
r int
g int
b int
}
var (
invoiceTeal = rgbColor{0, 123, 143}
invoiceLightGray = rgbColor{244, 244, 244}
invoiceMidGray = rgbColor{221, 221, 221}
invoiceTextGray = rgbColor{85, 85, 85}
invoiceNoteBg = rgbColor{237, 247, 249}
)
func NewPdfClient() (*HTMLPdfClient, error) {
return &HTMLPdfClient{}, nil
}
func (pc *HTMLPdfClient) BuildInvoice(data booking.Invoice) (*booking.GeneratedFile, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetMargins(16, 16, 16)
pdf.SetAutoPageBreak(true, 16)
pdf.SetMargins(invoiceMarginX, 12, invoiceMarginX)
pdf.SetAutoPageBreak(true, 14)
pdf.AddPage()
tr := pdf.UnicodeTranslatorFromDescriptor("")
writeLine := func(fontStyle string, fontSize float64, text string) {
pdf.SetFont("Helvetica", fontStyle, fontSize)
pdf.CellFormat(0, 7, tr(text), "", 1, "", false, 0, "")
}
writeLine("B", 20, fmt.Sprintf("Invoice %s", data.ID))
pdf.Ln(2)
writeLine("", 11, data.Host.Name)
writeLine("", 11, data.Host.Address)
writeLine("", 11, fmt.Sprintf("%s %s", data.Host.ZipCode, data.Host.City))
writeLine("", 11, data.Host.PhoneNumber)
writeLine("", 11, data.Host.Email)
pdf.Ln(4)
writeLine("B", 14, "Guest")
writeLine("", 11, data.Name)
if data.PhoneNumber != "" {
writeLine("", 11, data.PhoneNumber)
}
writeLine("", 11, fmt.Sprintf("Stay: %s - %s", data.From, data.To))
writeLine("", 11, fmt.Sprintf("Platform: %s", data.Platform))
writeLine("", 11, fmt.Sprintf("Guests: %d", data.CustomersNumber))
pdf.Ln(4)
writeLine("B", 14, "Charges")
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(90, 8, tr("Item"), "1", 0, "", false, 0, "")
pdf.CellFormat(25, 8, tr("Qty"), "1", 0, "C", false, 0, "")
pdf.CellFormat(35, 8, tr("Unit"), "1", 0, "R", false, 0, "")
pdf.CellFormat(25, 8, tr("Total"), "1", 1, "R", false, 0, "")
pdf.SetFont("Helvetica", "", 11)
for _, line := range data.Lines {
pdf.CellFormat(90, 8, tr(line.Name), "1", 0, "", false, 0, "")
pdf.CellFormat(25, 8, fmt.Sprintf("%d", line.Quantity), "1", 0, "C", false, 0, "")
pdf.CellFormat(35, 8, fmt.Sprintf("%s EUR", line.Price), "1", 0, "R", false, 0, "")
pdf.CellFormat(25, 8, fmt.Sprintf("%s EUR", line.Total), "1", 1, "R", false, 0, "")
}
pdf.Ln(4)
writeLine("B", 14, "Payments")
if len(data.Payments) == 0 {
writeLine("", 11, "No payment recorded")
} else {
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(45, 8, tr("Date"), "1", 0, "", false, 0, "")
pdf.CellFormat(80, 8, tr("Method"), "1", 0, "", false, 0, "")
pdf.CellFormat(50, 8, tr("Amount"), "1", 1, "R", false, 0, "")
pdf.SetFont("Helvetica", "", 11)
for _, payment := range data.Payments {
pdf.CellFormat(45, 8, tr(payment.Date), "1", 0, "", false, 0, "")
pdf.CellFormat(80, 8, tr(payment.Method), "1", 0, "", false, 0, "")
pdf.CellFormat(50, 8, fmt.Sprintf("%s EUR", payment.Amount), "1", 1, "R", false, 0, "")
}
}
pdf.Ln(4)
writeLine("B", 12, fmt.Sprintf("Total: %s EUR", data.Total))
writeLine("", 12, fmt.Sprintf("Paid: %s EUR", data.AmountPaid))
writeLine("B", 12, fmt.Sprintf("Balance due: %s EUR", data.BalanceDue))
drawInvoiceHeader(pdf, tr, data)
drawInvoiceDetails(pdf, tr, data)
drawInvoiceItemsTable(pdf, tr, data)
drawInvoicePayments(pdf, tr, data)
drawInvoiceSummary(pdf, tr, data)
drawInvoiceNotes(pdf, tr, data)
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
@ -118,3 +88,218 @@ func (pc *HTMLPdfClient) BuildReport(report booking.ReportData, period string, m
return outputPath, nil
}
func drawInvoiceHeader(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
pdf.SetFillColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b)
pdf.Rect(invoiceMarginX, 12, invoiceContentWidth, invoiceHeaderHeight, "F")
if logo, err := assets.Static.ReadFile("assets/img/logo.png"); err == nil {
info := pdf.RegisterImageOptionsReader(
"invoice-logo",
gofpdf.ImageOptions{ImageType: "PNG"},
bytes.NewReader(logo),
)
if info != nil {
pdf.ImageOptions("invoice-logo", 14, 15.5, 17, 17, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "")
}
}
pdf.SetTextColor(255, 255, 255)
pdf.SetXY(110, 14.5)
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(86, 4.8, tr(data.Host.Name), "", 1, "R", false, 0, "")
pdf.SetFont("Helvetica", "", 8.8)
hostLines := []string{
data.Host.Address,
fmt.Sprintf("%s %s", data.Host.ZipCode, data.Host.City),
fmt.Sprintf("Tel : %s", data.Host.PhoneNumber),
fmt.Sprintf("Mail : %s", data.Host.Email),
}
for _, line := range hostLines {
pdf.SetX(110)
pdf.CellFormat(86, 4.0, tr(line), "", 1, "R", false, 0, "")
}
pdf.SetTextColor(0, 0, 0)
pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b)
pdf.Line(invoiceMarginX, 45, invoiceMarginX+invoiceContentWidth, 45)
pdf.SetY(49)
}
func drawInvoiceDetails(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
leftX := invoiceMarginX + 4
rightX := 103.0
labelColor := invoiceTextGray
writeDetail := func(x float64, label, value string, boldValue bool) {
pdf.SetX(x)
pdf.SetFont("Helvetica", "B", 10.5)
pdf.SetTextColor(labelColor.r, labelColor.g, labelColor.b)
pdf.CellFormat(38, 7, tr(label), "", 0, "", false, 0, "")
pdf.SetTextColor(0, 0, 0)
style := ""
if boldValue {
style = "B"
}
pdf.SetFont("Helvetica", style, 10.5)
pdf.CellFormat(48, 7, tr(value), "", 1, "", false, 0, "")
}
pdf.SetFont("Helvetica", "B", 12)
pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.SetX(leftX)
pdf.CellFormat(86, 7, tr(data.Name), "", 0, "", false, 0, "")
pdf.SetX(rightX)
pdf.CellFormat(86, 7, tr("N° de facture :"), "", 0, "", false, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.SetFont("Helvetica", "", 11)
pdf.CellFormat(20, 7, tr(data.ID), "", 1, "R", false, 0, "")
writeDetail(leftX, "Tel :", emptyFallback(data.PhoneNumber), false)
writeDetail(rightX, "Du :", data.From, false)
writeDetail(leftX, "Nombre de clients :", fmt.Sprintf("%d", data.CustomersNumber), false)
writeDetail(rightX, "Au :", data.To, false)
writeDetail(leftX, "Plateforme :", data.Platform, false)
writeDetail(rightX, "Montant Total :", euroAmount(data.Total), false)
pdf.Ln(4)
pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b)
pdf.Line(invoiceMarginX, pdf.GetY(), invoiceMarginX+invoiceContentWidth, pdf.GetY())
pdf.Ln(5)
}
func drawInvoiceItemsTable(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
widths := []float64{70, 28, 39, 53}
headers := []string{"Objet", "Quantité", "Prix (€)", "Total (€)"}
writeTableHeader(pdf, tr, widths, headers)
pdf.SetFont("Helvetica", "", 10.5)
pdf.SetTextColor(0, 0, 0)
for i, line := range data.Lines {
fill := i%2 == 0
if fill {
pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b)
}
pdf.CellFormat(widths[0], invoiceTableRowH, tr(line.Name), "1", 0, "", fill, 0, "")
pdf.CellFormat(widths[1], invoiceTableRowH, fmt.Sprintf("%d", line.Quantity), "1", 0, "C", fill, 0, "")
pdf.CellFormat(widths[2], invoiceTableRowH, line.Price, "1", 0, "R", fill, 0, "")
pdf.CellFormat(widths[3], invoiceTableRowH, line.Total, "1", 1, "R", fill, 0, "")
}
pdf.Ln(6)
}
func drawInvoicePayments(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
pdf.SetFont("Helvetica", "B", 13)
pdf.SetTextColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b)
pdf.CellFormat(0, 7, tr("Historique des Paiements"), "", 1, "", false, 0, "")
pdf.Ln(1)
widths := []float64{54, 84, 52}
headers := []string{"Date", "Mode de Paiement", "Montant (€)"}
writeTableHeader(pdf, tr, widths, headers)
pdf.SetFont("Helvetica", "", 10.5)
pdf.SetTextColor(0, 0, 0)
if len(data.Payments) == 0 {
pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b)
pdf.CellFormat(widths[0], invoiceTableRowH, tr("-"), "1", 0, "", true, 0, "")
pdf.CellFormat(widths[1], invoiceTableRowH, tr("Aucun paiement enregistré"), "1", 0, "", true, 0, "")
pdf.CellFormat(widths[2], invoiceTableRowH, tr("0.00"), "1", 1, "R", true, 0, "")
} else {
for i, payment := range data.Payments {
fill := i%2 == 0
if fill {
pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b)
}
pdf.CellFormat(widths[0], invoiceTableRowH, tr(payment.Date), "1", 0, "", fill, 0, "")
pdf.CellFormat(widths[1], invoiceTableRowH, tr(payment.Method), "1", 0, "", fill, 0, "")
pdf.CellFormat(widths[2], invoiceTableRowH, payment.Amount, "1", 1, "R", fill, 0, "")
}
}
pdf.Ln(6)
}
func drawInvoiceSummary(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
labels := []string{"Montant Total :", "Montant Payé :", "Solde Restant :"}
values := []string{data.Total, data.AmountPaid, data.BalanceDue}
for i := range labels {
pdf.SetX(invoiceMarginX + 3)
pdf.SetFont("Helvetica", "B", 11)
pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.CellFormat(60, invoiceSummaryLineH, tr(labels[i]), "", 0, "", false, 0, "")
pdf.SetFont("Helvetica", "", 11)
pdf.SetTextColor(0, 0, 0)
pdf.CellFormat(95, invoiceSummaryLineH, "", "", 0, "", false, 0, "")
pdf.CellFormat(32, invoiceSummaryLineH, values[i], "", 1, "R", false, 0, "")
}
pdf.Ln(2)
pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b)
pdf.Line(invoiceMarginX, pdf.GetY(), invoiceMarginX+invoiceContentWidth, pdf.GetY())
pdf.Ln(6)
}
func drawInvoiceNotes(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
notes := "Notes\nTVA non applicable, art. 293 B du CGI\nDispensé d'immatriculation au registre du commerce et des sociétés (RCS) et au répertoire des métiers.\nConditions de paiement : paiement à réception de facture. Aucun escompte consenti pour règlement anticipé ou désistement. Tout incident de paiement est passible d'intérêts de retard. Le montant des pénalités résulte de l'application aux sommes restant dues d'un taux d'intérêt légal en vigueur au moment de l'incident.\nIl sera demandé 15€ par jour par personne supplémentaire."
pdf.SetFont("Helvetica", "", 7.5)
lines := pdf.SplitLines([]byte(tr(notes)), invoiceContentWidth-(invoiceNotesPadding*2))
noteBoxHeight := float64(len(lines))*3.2 + invoiceNotesPadding*2
totalBlockHeight := noteBoxHeight + 18
if pdf.GetY()+totalBlockHeight > 282 {
pdf.AddPage()
}
y := pdf.GetY()
pdf.SetFillColor(invoiceNoteBg.r, invoiceNoteBg.g, invoiceNoteBg.b)
pdf.Rect(invoiceMarginX, y, invoiceContentWidth, noteBoxHeight, "F")
pdf.SetXY(invoiceMarginX+invoiceNotesPadding, y+invoiceNotesPadding)
pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.MultiCell(invoiceContentWidth-(invoiceNotesPadding*2), 3.2, tr(notes), "", "L", false)
pdf.Ln(4)
pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b)
pdf.Line(invoiceMarginX, pdf.GetY(), invoiceMarginX+invoiceContentWidth, pdf.GetY())
pdf.Ln(4)
pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(0, 5, tr("Total"), "", 1, "R", false, 0, "")
pdf.SetTextColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b)
pdf.SetFont("Helvetica", "B", 15)
pdf.CellFormat(0, 7, tr(euroAmount(data.Total)), "", 0, "R", false, 0, "")
}
func writeTableHeader(pdf *gofpdf.Fpdf, tr func(string) string, widths []float64, headers []string) {
pdf.SetFillColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b)
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Helvetica", "B", 10)
alignments := []string{"L", "C", "R", "R"}
if len(widths) == 3 {
alignments = []string{"L", "L", "R"}
}
for i, header := range headers {
end := 0
if i == len(headers)-1 {
end = 1
}
pdf.CellFormat(widths[i], 8, tr(header), "1", end, alignments[i], true, 0, "")
}
}
func euroAmount(value string) string {
return fmt.Sprintf("%s €", value)
}
func emptyFallback(value string) string {
if value == "" {
return "-"
}
return value
}