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 }