rentease/internal/driver/pdf/html.go
Ruidy e8bfcf26f3
Some checks failed
CI / checks (push) Has been cancelled
feat(pdf): improve invoice PDF layout and style
Redesigned the invoice PDF with a more modern, modular layout. Updated
colors, spacing, and section organization for better readability and
visual appeal. Added a summary block, improved table formatting, and
refined header/footer presentation. Also updated dependencies in go.mod
and go.sum to support new features.
2026-03-27 14:48:41 +01:00

447 lines
16 KiB
Go

package pdf
import (
"bytes"
"fmt"
"log"
"os"
"text/template"
"time"
"github.com/jung-kurt/gofpdf/v2"
"github.com/rjNemo/rentease/assets"
"github.com/rjNemo/rentease/internal/service/booking"
)
type HTMLPdfClient struct{}
const (
invoiceMarginX = 10.0
invoiceContentWidth = 190.0
invoiceHeaderHeight = 32.0
invoiceTableRowH = 9.0
invoiceSummaryLineH = 9.0
invoiceNotesPadding = 4.0
invoiceColGap = 6.0
invoiceColWidth = (invoiceContentWidth - invoiceColGap) / 2.0
)
type rgbColor struct {
r int
g int
b int
}
var (
invoiceTeal = rgbColor{0, 123, 143}
invoiceTealDark = rgbColor{0, 98, 114}
invoiceLightGray = rgbColor{248, 249, 250}
invoiceMidGray = rgbColor{222, 226, 230}
invoiceTextGray = rgbColor{73, 80, 87}
invoiceTextMuted = rgbColor{108, 117, 125}
invoiceNoteBg = rgbColor{237, 247, 249}
invoiceWhite = rgbColor{255, 255, 255}
)
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(invoiceMarginX, 10, invoiceMarginX)
pdf.SetAutoPageBreak(true, 14)
pdf.AddPage()
tr := pdf.UnicodeTranslatorFromDescriptor("")
drawInvoiceHeader(pdf, tr, data)
drawInvoiceDetails(pdf, tr, data)
drawInvoiceItemsTable(pdf, tr, data)
drawInvoicePayments(pdf, tr, data)
drawInvoiceSummary(pdf, tr, data)
drawInvoiceNotes(pdf, tr, data)
drawInvoiceFooter(pdf, tr, data)
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("error writing PDF file: %v", err)
}
return &booking.GeneratedFile{
Name: fmt.Sprintf("%s.pdf", data.ID),
ContentType: "application/pdf",
Data: buf.Bytes(),
}, nil
}
func (pc *HTMLPdfClient) BuildReport(report booking.ReportData, period string, month int, year int) (string, error) {
tmpl, err := template.ParseFS(assets.Static, "assets/html/report.html")
if err != nil {
return "", fmt.Errorf("error parsing template: %v", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, report); err != nil {
log.Printf("err: %+v\n", err)
return "", fmt.Errorf("error executing template: %v", err)
}
outputPath := fmt.Sprintf("report-%s-%d-%d.html", period, month, year)
if err := os.WriteFile(outputPath, buf.Bytes(), 0o644); err != nil {
log.Printf("err: %+v\n", err)
return "", fmt.Errorf("error writing HTML file: %v", err)
}
return outputPath, nil
}
func drawInvoiceHeader(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
topY := 10.0
// Full-width teal banner
pdf.SetFillColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b)
pdf.Rect(invoiceMarginX, topY, invoiceContentWidth, invoiceHeaderHeight, "F")
// Subtle darker teal accent strip at very top
pdf.SetFillColor(invoiceTealDark.r, invoiceTealDark.g, invoiceTealDark.b)
pdf.Rect(invoiceMarginX, topY, invoiceContentWidth, 1.2, "F")
// Logo in a white rounded rectangle
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 {
logoX := invoiceMarginX + 5
logoY := topY + 4
logoSize := 24.0
// white background circle behind logo
pdf.SetFillColor(255, 255, 255)
pdf.SetDrawColor(255, 255, 255)
radius := logoSize/2 + 1.5
pdf.Circle(logoX+logoSize/2, logoY+logoSize/2, radius, "F")
pdf.ImageOptions("invoice-logo", logoX+1.5, logoY+1.5, logoSize-3, logoSize-3, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "")
}
}
// Host info on the right side of banner
pdf.SetTextColor(255, 255, 255)
rightBlockX := 100.0
rightBlockW := invoiceMarginX + invoiceContentWidth - rightBlockX - 4
pdf.SetXY(rightBlockX, topY+5)
pdf.SetFont("Helvetica", "B", 13)
pdf.CellFormat(rightBlockW, 5.5, tr(data.Host.Name), "", 1, "R", false, 0, "")
pdf.SetFont("Helvetica", "", 8.5)
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(rightBlockX)
pdf.CellFormat(rightBlockW, 4.2, tr(line), "", 1, "R", false, 0, "")
}
// Invoice number badge below banner
pdf.Ln(5)
badgeY := pdf.GetY()
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b)
pdf.SetX(invoiceMarginX)
pdf.CellFormat(80, 6, tr("FACTURE"), "", 0, "L", false, 0, "")
pdf.SetFont("Helvetica", "B", 14)
pdf.SetTextColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b)
pdf.CellFormat(invoiceContentWidth-80, 6, tr(data.ID), "", 1, "R", false, 0, "")
pdf.SetDrawColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b)
pdf.SetLineWidth(0.4)
pdf.Line(invoiceMarginX, badgeY+8, invoiceMarginX+invoiceContentWidth, badgeY+8)
pdf.SetLineWidth(0.2)
pdf.SetY(badgeY + 12)
}
func drawInvoiceDetails(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
startY := pdf.GetY()
leftX := invoiceMarginX
rightX := invoiceMarginX + invoiceColWidth + invoiceColGap
cardPad := 5.0
cardH := 36.0
// Left card: Customer info - light gray background
pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b)
pdf.RoundedRect(leftX, startY, invoiceColWidth, cardH, 2, "1234", "F")
// Section label
pdf.SetXY(leftX+cardPad, startY+cardPad)
pdf.SetFont("Helvetica", "", 7.5)
pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b)
pdf.CellFormat(invoiceColWidth-cardPad*2, 4, tr("CLIENT"), "", 1, "L", false, 0, "")
// Customer name
pdf.SetX(leftX + cardPad)
pdf.SetFont("Helvetica", "B", 12)
pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.CellFormat(invoiceColWidth-cardPad*2, 7, tr(data.Name), "", 1, "L", false, 0, "")
// Customer details
detailLineH := 5.5
writeLeftDetail := func(label, value string) {
pdf.SetX(leftX + cardPad)
pdf.SetFont("Helvetica", "", 8.5)
pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b)
pdf.CellFormat(28, detailLineH, tr(label), "", 0, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.CellFormat(invoiceColWidth-cardPad*2-28, detailLineH, tr(value), "", 1, "L", false, 0, "")
}
writeLeftDetail("Tel", emptyFallback(data.PhoneNumber))
writeLeftDetail("Clients", fmt.Sprintf("%d", data.CustomersNumber))
writeLeftDetail("Plateforme", data.Platform)
// Right card: Booking info - light gray background
pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b)
pdf.RoundedRect(rightX, startY, invoiceColWidth, cardH, 2, "1234", "F")
pdf.SetXY(rightX+cardPad, startY+cardPad)
pdf.SetFont("Helvetica", "", 7.5)
pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b)
pdf.CellFormat(invoiceColWidth-cardPad*2, 4, tr("SÉJOUR"), "", 1, "L", false, 0, "")
writeRightDetail := func(label, value string, bold bool) {
pdf.SetX(rightX + cardPad)
pdf.SetFont("Helvetica", "", 8.5)
pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b)
pdf.CellFormat(28, detailLineH, tr(label), "", 0, "L", false, 0, "")
style := ""
if bold {
style = "B"
}
pdf.SetFont("Helvetica", style, 9.5)
pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.CellFormat(invoiceColWidth-cardPad*2-28, detailLineH, tr(value), "", 1, "L", false, 0, "")
}
// Date range as main heading
pdf.SetX(rightX + cardPad)
pdf.SetFont("Helvetica", "B", 12)
pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.CellFormat(invoiceColWidth-cardPad*2, 7, tr(fmt.Sprintf("%s — %s", data.From, data.To)), "", 1, "L", false, 0, "")
writeRightDetail("Nuits", nightsCount(data.From, data.To), false)
writeRightDetail("Montant", euroAmount(data.Total), true)
pdf.SetY(startY + cardH + 8)
}
func drawInvoiceItemsTable(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
// Section title
pdf.SetFont("Helvetica", "B", 11)
pdf.SetTextColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b)
pdf.CellFormat(0, 7, tr("Prestations"), "", 1, "L", false, 0, "")
pdf.Ln(1)
widths := []float64{72, 30, 40, 48}
headers := []string{"Objet", "Quantité", "Prix (€)", "Total (€)"}
writeTableHeader(pdf, tr, widths, headers)
pdf.SetFont("Helvetica", "", 10)
for i, line := range data.Lines {
fill := i%2 == 0
if fill {
pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b)
}
pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.CellFormat(widths[0], invoiceTableRowH, " "+tr(line.Name), "", 0, "", fill, 0, "")
pdf.CellFormat(widths[1], invoiceTableRowH, fmt.Sprintf("%d", line.Quantity), "", 0, "C", fill, 0, "")
pdf.CellFormat(widths[2], invoiceTableRowH, line.Price, "", 0, "R", fill, 0, "")
pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.SetFont("Helvetica", "B", 10)
pdf.CellFormat(widths[3], invoiceTableRowH, line.Total+" ", "", 1, "R", fill, 0, "")
pdf.SetFont("Helvetica", "", 10)
// Subtle separator line
lineY := pdf.GetY()
pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b)
pdf.Line(invoiceMarginX, lineY, invoiceMarginX+invoiceContentWidth, lineY)
}
pdf.Ln(8)
}
func drawInvoicePayments(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
pdf.SetFont("Helvetica", "B", 11)
pdf.SetTextColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b)
pdf.CellFormat(0, 7, tr("Historique des Paiements"), "", 1, "L", false, 0, "")
pdf.Ln(1)
widths := []float64{54, 88, 48}
headers := []string{"Date", "Mode de Paiement", "Montant (€)"}
writeTableHeader(pdf, tr, widths, headers)
pdf.SetFont("Helvetica", "", 10)
if len(data.Payments) == 0 {
pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b)
pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b)
pdf.CellFormat(widths[0], invoiceTableRowH, " "+tr("-"), "", 0, "", true, 0, "")
pdf.CellFormat(widths[1], invoiceTableRowH, tr("Aucun paiement enregistré"), "", 0, "", true, 0, "")
pdf.CellFormat(widths[2], invoiceTableRowH, tr("0.00")+" ", "", 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.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.CellFormat(widths[0], invoiceTableRowH, " "+tr(payment.Date), "", 0, "", fill, 0, "")
pdf.CellFormat(widths[1], invoiceTableRowH, tr(payment.Method), "", 0, "", fill, 0, "")
pdf.SetFont("Helvetica", "B", 10)
pdf.CellFormat(widths[2], invoiceTableRowH, payment.Amount+" ", "", 1, "R", fill, 0, "")
pdf.SetFont("Helvetica", "", 10)
lineY := pdf.GetY()
pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b)
pdf.Line(invoiceMarginX, lineY, invoiceMarginX+invoiceContentWidth, lineY)
}
}
pdf.Ln(8)
}
func drawInvoiceSummary(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
// Right-aligned summary block
summaryX := invoiceMarginX + invoiceContentWidth - 80
summaryW := 80.0
labels := []string{"Montant Total", "Montant Payé", "Solde Restant"}
values := []string{euroAmount(data.Total), euroAmount(data.AmountPaid), euroAmount(data.BalanceDue)}
for i := range labels {
pdf.SetX(summaryX)
pdf.SetFont("Helvetica", "", 9.5)
pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b)
pdf.CellFormat(summaryW/2, invoiceSummaryLineH, tr(labels[i]), "", 0, "L", false, 0, "")
pdf.SetFont("Helvetica", "B", 10)
pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.CellFormat(summaryW/2, invoiceSummaryLineH, tr(values[i]), "", 1, "R", false, 0, "")
if i < len(labels)-1 {
lineY := pdf.GetY()
pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b)
pdf.Line(summaryX, lineY, summaryX+summaryW, lineY)
}
}
// Highlight the balance due
balanceY := pdf.GetY() - invoiceSummaryLineH
pdf.SetFillColor(invoiceNoteBg.r, invoiceNoteBg.g, invoiceNoteBg.b)
pdf.RoundedRect(summaryX-2, balanceY-0.5, summaryW+4, invoiceSummaryLineH+1, 1.5, "1234", "F")
pdf.SetXY(summaryX, balanceY)
pdf.SetFont("Helvetica", "B", 9.5)
pdf.SetTextColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b)
pdf.CellFormat(summaryW/2, invoiceSummaryLineH, tr("Solde Restant"), "", 0, "L", false, 0, "")
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(summaryW/2, invoiceSummaryLineH, tr(euroAmount(data.BalanceDue)), "", 1, "R", false, 0, "")
pdf.Ln(8)
}
func drawInvoiceNotes(pdf *gofpdf.Fpdf, tr func(string) string, _ booking.Invoice) {
notes := "TVA 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)
lines := pdf.SplitLines([]byte(tr(notes)), invoiceContentWidth-(invoiceNotesPadding*2)-8)
noteBoxHeight := float64(len(lines))*3.0 + invoiceNotesPadding*2 + 6
if pdf.GetY()+noteBoxHeight+30 > 282 {
pdf.AddPage()
}
y := pdf.GetY()
pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b)
pdf.RoundedRect(invoiceMarginX, y, invoiceContentWidth, noteBoxHeight, 2, "1234", "F")
// Notes title
pdf.SetXY(invoiceMarginX+invoiceNotesPadding, y+invoiceNotesPadding)
pdf.SetFont("Helvetica", "B", 7.5)
pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b)
pdf.CellFormat(invoiceContentWidth-(invoiceNotesPadding*2), 4, tr("MENTIONS LÉGALES"), "", 1, "L", false, 0, "")
pdf.SetX(invoiceMarginX + invoiceNotesPadding)
pdf.SetFont("Helvetica", "", 7)
pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b)
pdf.MultiCell(invoiceContentWidth-(invoiceNotesPadding*2), 3.0, tr(notes), "", "L", false)
pdf.SetY(y + noteBoxHeight + 6)
}
func drawInvoiceFooter(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
// Total banner at the bottom
footerH := 14.0
y := pdf.GetY()
pdf.SetFillColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b)
pdf.RoundedRect(invoiceMarginX, y, invoiceContentWidth, footerH, 2, "1234", "F")
pdf.SetXY(invoiceMarginX+6, y+2)
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(255, 255, 255)
pdf.CellFormat(60, 10, tr("TOTAL À PAYER"), "", 0, "L", false, 0, "")
pdf.SetFont("Helvetica", "B", 16)
pdf.CellFormat(invoiceContentWidth-72, 10, 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", 9)
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
}
pad := ""
if i == 0 {
pad = " "
}
pdf.CellFormat(widths[i], 8, pad+tr(header), "", end, alignments[i], true, 0, "")
}
}
func nightsCount(from, to string) string {
f, err1 := time.Parse("02/01/2006", from)
t, err2 := time.Parse("02/01/2006", to)
if err1 != nil || err2 != nil {
return "-"
}
days := int(t.Sub(f).Hours() / 24)
if days < 0 {
days = 0
}
return fmt.Sprintf("%d", days)
}
func euroAmount(value string) string {
return fmt.Sprintf("%s €", value)
}
func emptyFallback(value string) string {
if value == "" {
return "-"
}
return value
}