feat(pdf): improve invoice PDF layout and style
Some checks failed
CI / checks (push) Has been cancelled

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.
This commit is contained in:
Ruidy 2026-03-27 14:48:41 +01:00
parent 24f289b767
commit e8bfcf26f3
No known key found for this signature in database
GPG key ID: 705C24D202990805
3 changed files with 287 additions and 137 deletions

19
go.mod
View file

@ -4,11 +4,13 @@ go 1.26
require (
github.com/a-h/templ v0.3.1001
github.com/getsentry/sentry-go v0.43.0
github.com/getsentry/sentry-go v0.44.1
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1
github.com/jung-kurt/gofpdf/v2 v2.17.3
github.com/minio/minio-go/v7 v7.0.99
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/rjNemo/underscore v0.10.0
github.com/stripe/stripe-go/v83 v83.2.1
@ -21,30 +23,29 @@ require (
github.com/go-ini/ini v1.67.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jung-kurt/gofpdf/v2 v2.17.3 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.99 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
)
require (
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect

39
go.sum
View file

@ -2,13 +2,14 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4=
github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI=
github.com/getsentry/sentry-go v0.44.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@ -31,8 +32,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -43,13 +44,17 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jung-kurt/gofpdf/v2 v2.17.3 h1:otZXZby2gXJ7uU6pzprXHq/R57lsHLi0WtH79VabWxY=
github.com/jung-kurt/gofpdf/v2 v2.17.3/go.mod h1:Qx8ZNg4cNsO5i6uLDiBngnm+ii/FjtAqjRNO6drsoYU=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
@ -70,6 +75,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rjNemo/underscore v0.10.0 h1:f0WTiHXujG9mgbEt51VH06TqLMS5n4EUKtp5wzhBqQM=
github.com/rjNemo/underscore v0.10.0/go.mod h1:g2nURJw5INpBuh8ie0AjK1KLnsHSA+Tzc+oXiooq7ms=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U=
@ -92,18 +99,16 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
@ -111,6 +116,8 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -6,6 +6,7 @@ import (
"log"
"os"
"text/template"
"time"
"github.com/jung-kurt/gofpdf/v2"
@ -18,10 +19,12 @@ type HTMLPdfClient struct{}
const (
invoiceMarginX = 10.0
invoiceContentWidth = 190.0
invoiceHeaderHeight = 28.0
invoiceTableRowH = 8.5
invoiceSummaryLineH = 10.0
invoiceNotesPadding = 3.0
invoiceHeaderHeight = 32.0
invoiceTableRowH = 9.0
invoiceSummaryLineH = 9.0
invoiceNotesPadding = 4.0
invoiceColGap = 6.0
invoiceColWidth = (invoiceContentWidth - invoiceColGap) / 2.0
)
type rgbColor struct {
@ -32,10 +35,13 @@ type rgbColor struct {
var (
invoiceTeal = rgbColor{0, 123, 143}
invoiceLightGray = rgbColor{244, 244, 244}
invoiceMidGray = rgbColor{221, 221, 221}
invoiceTextGray = rgbColor{85, 85, 85}
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) {
@ -44,7 +50,7 @@ func NewPdfClient() (*HTMLPdfClient, error) {
func (pc *HTMLPdfClient) BuildInvoice(data booking.Invoice) (*booking.GeneratedFile, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetMargins(invoiceMarginX, 12, invoiceMarginX)
pdf.SetMargins(invoiceMarginX, 10, invoiceMarginX)
pdf.SetAutoPageBreak(true, 14)
pdf.AddPage()
@ -55,6 +61,7 @@ func (pc *HTMLPdfClient) BuildInvoice(data booking.Invoice) (*booking.GeneratedF
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 {
@ -90,9 +97,17 @@ func (pc *HTMLPdfClient) BuildReport(report booking.ReportData, period string, m
}
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")
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",
@ -100,15 +115,28 @@ func drawInvoiceHeader(pdf *gofpdf.Fpdf, tr func(string) string, data booking.In
bytes.NewReader(logo),
)
if info != nil {
pdf.ImageOptions("invoice-logo", 14, 15.5, 17, 17, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "")
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)
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)
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),
@ -116,170 +144,267 @@ func drawInvoiceHeader(pdf *gofpdf.Fpdf, tr func(string) string, data booking.In
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.SetX(rightBlockX)
pdf.CellFormat(rightBlockW, 4.2, 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)
// 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) {
leftX := invoiceMarginX + 4
rightX := 103.0
labelColor := invoiceTextGray
startY := pdf.GetY()
leftX := invoiceMarginX
rightX := invoiceMarginX + invoiceColWidth + invoiceColGap
cardPad := 5.0
cardH := 36.0
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, "")
}
// 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.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, "")
pdf.CellFormat(invoiceColWidth-cardPad*2, 7, tr(data.Name), "", 1, "L", false, 0, "")
writeDetail(leftX, "Tel :", emptyFallback(data.PhoneNumber), false)
writeDetail(rightX, "Du :", data.From, false)
// 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, "")
}
writeDetail(leftX, "Nombre de clients :", fmt.Sprintf("%d", data.CustomersNumber), false)
writeDetail(rightX, "Au :", data.To, false)
writeLeftDetail("Tel", emptyFallback(data.PhoneNumber))
writeLeftDetail("Clients", fmt.Sprintf("%d", data.CustomersNumber))
writeLeftDetail("Plateforme", data.Platform)
writeDetail(leftX, "Plateforme :", data.Platform, false)
writeDetail(rightX, "Montant Total :", euroAmount(data.Total), false)
// 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.Ln(4)
pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b)
pdf.Line(invoiceMarginX, pdf.GetY(), invoiceMarginX+invoiceContentWidth, pdf.GetY())
pdf.Ln(5)
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) {
widths := []float64{70, 28, 39, 53}
// 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.5)
pdf.SetTextColor(0, 0, 0)
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.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.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(6)
pdf.Ln(8)
}
func drawInvoicePayments(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) {
pdf.SetFont("Helvetica", "B", 13)
pdf.SetFont("Helvetica", "B", 11)
pdf.SetTextColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b)
pdf.CellFormat(0, 7, tr("Historique des Paiements"), "", 1, "", false, 0, "")
pdf.CellFormat(0, 7, tr("Historique des Paiements"), "", 1, "L", false, 0, "")
pdf.Ln(1)
widths := []float64{54, 84, 52}
widths := []float64{54, 88, 48}
headers := []string{"Date", "Mode de Paiement", "Montant (€)"}
writeTableHeader(pdf, tr, widths, headers)
pdf.SetFont("Helvetica", "", 10.5)
pdf.SetTextColor(0, 0, 0)
pdf.SetFont("Helvetica", "", 10)
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, "")
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.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.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(6)
pdf.Ln(8)
}
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}
// 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(invoiceMarginX + 3)
pdf.SetFont("Helvetica", "B", 11)
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(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.CellFormat(summaryW/2, invoiceSummaryLineH, tr(values[i]), "", 1, "R", false, 0, "")
pdf.Ln(2)
if i < len(labels)-1 {
lineY := pdf.GetY()
pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b)
pdf.Line(invoiceMarginX, pdf.GetY(), invoiceMarginX+invoiceContentWidth, pdf.GetY())
pdf.Ln(6)
pdf.Line(summaryX, lineY, summaryX+summaryW, lineY)
}
}
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."
// 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.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.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(invoiceNoteBg.r, invoiceNoteBg.g, invoiceNoteBg.b)
pdf.Rect(invoiceMarginX, y, invoiceContentWidth, noteBoxHeight, "F")
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.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b)
pdf.MultiCell(invoiceContentWidth-(invoiceNotesPadding*2), 3.2, tr(notes), "", "L", false)
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.Ln(4)
pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b)
pdf.Line(invoiceMarginX, pdf.GetY(), invoiceMarginX+invoiceContentWidth, pdf.GetY())
pdf.Ln(4)
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.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, "")
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", 10)
pdf.SetFont("Helvetica", "B", 9)
alignments := []string{"L", "C", "R", "R"}
if len(widths) == 3 {
alignments = []string{"L", "L", "R"}
@ -289,8 +414,25 @@ func writeTableHeader(pdf *gofpdf.Fpdf, tr func(string) string, widths []float64
if i == len(headers)-1 {
end = 1
}
pdf.CellFormat(widths[i], 8, tr(header), "1", end, alignments[i], true, 0, "")
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 {