Invoice generation working

This commit is contained in:
Finley Ghosh 2026-01-13 22:13:06 +11:00
parent a33eaa0c3c
commit 2673886033
8 changed files with 1153 additions and 154 deletions

View file

@ -10,6 +10,7 @@ import (
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/attachments"
quotes "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/quotes"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
@ -70,9 +71,6 @@ func main() {
// Static files
goRouter.PathPrefix("/static/").Handler(http.StripPrefix("/go/static/", http.FileServer(http.Dir("static"))))
// PDF files
goRouter.PathPrefix("/pdf/").Handler(http.StripPrefix("/go/pdf/", http.FileServer(http.Dir("webroot/pdf"))))
// Quote routes
goRouter.HandleFunc("/quotes", quoteHandler.QuotesOutstandingView).Methods("GET")
goRouter.HandleFunc("/quotes/send-reminder", quoteHandler.SendManualReminder).Methods("POST")
@ -84,6 +82,22 @@ func main() {
goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET")
goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE")
// PDF generation routes
goRouter.HandleFunc("/pdf/generate-invoice", handlers.GenerateInvoicePDF).Methods("POST")
goRouter.HandleFunc("/pdf/generate-quote", handlers.GenerateQuotePDF).Methods("POST")
goRouter.HandleFunc("/pdf/generate-po", handlers.GeneratePurchaseOrderPDF).Methods("POST")
goRouter.HandleFunc("/pdf/generate-packinglist", handlers.GeneratePackingListPDF).Methods("POST")
goRouter.HandleFunc("/pdf/generate-orderack", handlers.GenerateOrderAckPDF).Methods("POST")
goRouter.HandleFunc("/pdf/count-pages", handlers.CountPages).Methods("POST")
// Serve generated PDFs
pdfDir := os.Getenv("PDF_OUTPUT_DIR")
if pdfDir == "" {
g
pdfDir = "webroot/pdf"
}
goRouter.PathPrefix("/pdf/").Handler(http.StripPrefix("/go/pdf/", http.FileServer(http.Dir(pdfDir))))
// The following routes are currently disabled:
/*
// API routes

View file

@ -1,22 +1,51 @@
module code.springupsoftware.com/cmc/cmc-sales
go 1.23.0
go 1.24.0
toolchain go1.24.3
require (
github.com/go-co-op/gocron v1.37.0
github.com/go-sql-driver/mysql v1.7.1
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/jhillyerd/enmime v1.3.0
github.com/joho/godotenv v1.5.1
github.com/jung-kurt/gofpdf v1.16.2
golang.org/x/text v0.27.0
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.259.0
)
require (
github.com/go-co-op/gocron v1.37.0 // indirect
github.com/google/uuid v1.4.0 // indirect
cloud.google.com/go/auth v0.18.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View file

@ -1,19 +1,44 @@
cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
@ -31,6 +56,11 @@ github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NB
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -38,11 +68,16 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -51,23 +86,56 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
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/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ=
google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
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

@ -0,0 +1,602 @@
package handlers
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/pdf"
)
// InvoiceLineItemRequest is the JSON shape for a single line item.
type InvoiceLineItemRequest struct {
ItemNumber string `json:"item_number"`
Quantity string `json:"quantity"`
Title string `json:"title"`
Description string `json:"description"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
NetUnitPrice float64 `json:"net_unit_price"`
NetPrice float64 `json:"net_price"`
DiscountPercent float64 `json:"discount_percent"`
DiscountAmountUnit float64 `json:"discount_amount_unit"`
DiscountAmountTotal float64 `json:"discount_amount_total"`
Option int `json:"option"`
HasTextPrices bool `json:"has_text_prices"`
UnitPriceString string `json:"unit_price_string"`
GrossPriceString string `json:"gross_price_string"`
}
// InvoicePDFRequest is the expected payload from the PHP app.
type InvoicePDFRequest struct {
DocumentID int32 `json:"document_id"`
InvoiceTitle string `json:"invoice_title"`
CustomerName string `json:"customer_name"`
ContactEmail string `json:"contact_email"`
ContactName string `json:"contact_name"`
UserFirstName string `json:"user_first_name"`
UserLastName string `json:"user_last_name"`
UserEmail string `json:"user_email"`
YourReference string `json:"your_reference"`
ShipVia string `json:"ship_via"`
FOB string `json:"fob"`
IssueDate string `json:"issue_date"` // ISO date: 2006-01-02
IssueDateString string `json:"issue_date_string"` // Formatted: "12 January 2026"
CurrencySymbol string `json:"currency_symbol"` // e.g. "$"
CurrencyCode string `json:"currency_code"` // e.g. "AUD", "USD"
ShowGST bool `json:"show_gst"`
BillTo string `json:"bill_to"`
ShipTo string `json:"ship_to"`
ShippingDetails string `json:"shipping_details"`
CustomerOrderNumber string `json:"customer_order_number"`
JobTitle string `json:"job_title"`
PaymentTerms string `json:"payment_terms"`
CustomerABN string `json:"customer_abn"`
Subtotal interface{} `json:"subtotal"` // Can be float or "TBA"
GSTAmount interface{} `json:"gst_amount"`
Total interface{} `json:"total"`
LineItems []InvoiceLineItemRequest `json:"line_items"`
OutputDir string `json:"output_dir"` // optional override
}
// GenerateInvoicePDF handles POST /api/pdf/invoice and writes a PDF to disk.
// It returns JSON: {"filename":"<name>.pdf"}
func GenerateInvoicePDF(w http.ResponseWriter, r *http.Request) {
var req InvoicePDFRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
return
}
if req.InvoiceTitle == "" || req.CustomerName == "" {
http.Error(w, "invoice_title and customer_name are required", http.StatusBadRequest)
return
}
issueDate := time.Now()
if req.IssueDate != "" {
if parsed, err := time.Parse("2006-01-02", req.IssueDate); err == nil {
issueDate = parsed
}
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = os.Getenv("PDF_OUTPUT_DIR")
}
if outputDir == "" {
outputDir = "../php/app/webroot/pdf"
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
log.Printf("GenerateInvoicePDF: failed to create output dir: %v", err)
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
return
}
// Map request into the existing PDF generation types.
doc := &db.Document{ID: req.DocumentID}
inv := &db.Invoice{Title: req.InvoiceTitle}
cust := &db.Customer{Name: req.CustomerName}
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
for i, li := range req.LineItems {
lineItems[i] = db.GetLineItemsTableRow{
ItemNumber: li.ItemNumber,
Quantity: li.Quantity,
Title: li.Title,
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
}
}
// Convert interface{} totals to float64
subtotal := 0.0
if val, ok := req.Subtotal.(float64); ok {
subtotal = val
}
gstAmount := 0.0
if val, ok := req.GSTAmount.(float64); ok {
gstAmount = val
}
total := 0.0
if val, ok := req.Total.(float64); ok {
total = val
}
data := &pdf.InvoicePDFData{
Document: doc,
Invoice: inv,
Customer: cust,
LineItems: lineItems,
CurrencySymbol: req.CurrencySymbol,
CurrencyCode: req.CurrencyCode,
ShowGST: req.ShowGST,
ShipVia: req.ShipVia,
FOB: req.FOB,
IssueDate: issueDate,
IssueDateString: req.IssueDateString,
EmailTo: req.ContactEmail,
Attention: req.ContactName,
FromName: fmt.Sprintf("%s %s", req.UserFirstName, req.UserLastName),
FromEmail: req.UserEmail,
YourReference: req.YourReference,
BillTo: req.BillTo,
ShipTo: req.ShipTo,
ShippingDetails: req.ShippingDetails,
CustomerOrderNumber: req.CustomerOrderNumber,
JobTitle: req.JobTitle,
PaymentTerms: req.PaymentTerms,
CustomerABN: req.CustomerABN,
Subtotal: subtotal,
GSTAmount: gstAmount,
Total: total,
}
filename, err := pdf.GenerateInvoicePDF(data, outputDir)
if err != nil {
log.Printf("GenerateInvoicePDF: failed to generate PDF: %v", err)
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
}
// QuoteLineItemRequest reuses the invoice item shape
type QuoteLineItemRequest = InvoiceLineItemRequest
// QuotePDFRequest payload from PHP for quotes
type QuotePDFRequest struct {
DocumentID int32 `json:"document_id"`
CmcReference string `json:"cmc_reference"`
Revision int32 `json:"revision"`
CreatedDate string `json:"created_date"` // YYYY-MM-DD
CreatedDateString string `json:"created_date_string"` // j M Y format
DateIssued string `json:"date_issued"`
CustomerName string `json:"customer_name"`
ContactEmail string `json:"contact_email"`
ContactName string `json:"contact_name"`
UserFirstName string `json:"user_first_name"`
UserLastName string `json:"user_last_name"`
UserEmail string `json:"user_email"`
CurrencySymbol string `json:"currency_symbol"`
CurrencyCode string `json:"currency_code"`
ShowGST bool `json:"show_gst"`
CommercialComments string `json:"commercial_comments"`
DeliveryTime string `json:"delivery_time"`
PaymentTerms string `json:"payment_terms"`
DaysValid int32 `json:"daysValid"`
DeliveryPoint string `json:"delivery_point"`
ExchangeRate string `json:"exchange_rate"`
CustomsDuty string `json:"customs_duty"`
GSTPhrase string `json:"gst_phrase"`
SalesEngineer string `json:"sales_engineer"`
BillTo string `json:"bill_to"`
ShipTo string `json:"ship_to"`
LineItems []QuoteLineItemRequest `json:"line_items"`
Pages []string `json:"pages"`
OutputDir string `json:"output_dir"`
}
// GenerateQuotePDF handles POST /go/pdf/generate-quote
func GenerateQuotePDF(w http.ResponseWriter, r *http.Request) {
var req QuotePDFRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
return
}
if req.CmcReference == "" || req.CustomerName == "" {
http.Error(w, "cmc_reference and customer_name are required", http.StatusBadRequest)
return
}
created := time.Now()
if req.CreatedDate != "" {
if parsed, err := time.Parse("2006-01-02", req.CreatedDate); err == nil {
created = parsed
}
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = os.Getenv("PDF_OUTPUT_DIR")
}
if outputDir == "" {
outputDir = "../php/app/webroot/pdf"
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
log.Printf("GenerateQuotePDF: failed to create output dir: %v", err)
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
return
}
// Map request into PDF data
doc := &db.Document{ID: req.DocumentID, CmcReference: req.CmcReference, Revision: req.Revision, Created: created}
cust := &db.Customer{Name: req.CustomerName}
user := &db.GetUserRow{FirstName: req.UserFirstName, LastName: req.UserLastName, Email: req.UserEmail}
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
for i, li := range req.LineItems {
lineItems[i] = db.GetLineItemsTableRow{
ItemNumber: li.ItemNumber,
Quantity: li.Quantity,
Title: li.Title,
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
}
}
data := &pdf.QuotePDFData{
Document: doc,
Customer: cust,
EmailTo: req.ContactEmail,
Attention: req.ContactName,
User: user,
LineItems: lineItems,
CurrencySymbol: req.CurrencySymbol,
CurrencyCode: req.CurrencyCode,
ShowGST: req.ShowGST,
CommercialComments: req.CommercialComments,
DeliveryTime: req.DeliveryTime,
PaymentTerms: req.PaymentTerms,
DaysValid: int(req.DaysValid),
DeliveryPoint: req.DeliveryPoint,
ExchangeRate: req.ExchangeRate,
CustomsDuty: req.CustomsDuty,
GSTPhrase: req.GSTPhrase,
SalesEngineer: req.SalesEngineer,
BillTo: req.BillTo,
ShipTo: req.ShipTo,
IssueDateString: req.CreatedDateString,
Pages: req.Pages,
}
filename, err := pdf.GenerateQuotePDF(data, outputDir)
if err != nil {
log.Printf("GenerateQuotePDF: failed to generate PDF: %v", err)
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
}
// PurchaseOrderLineItemRequest reuses the invoice item shape
type PurchaseOrderLineItemRequest = InvoiceLineItemRequest
// PurchaseOrderPDFRequest payload from PHP for POs
type PurchaseOrderPDFRequest struct {
DocumentID int32 `json:"document_id"`
Title string `json:"title"`
IssueDate string `json:"issue_date"` // YYYY-MM-DD
IssueDateString string `json:"issue_date_string"` // formatted date
PrincipleName string `json:"principle_name"`
PrincipleReference string `json:"principle_reference"`
OrderedFrom string `json:"ordered_from"`
DispatchBy string `json:"dispatch_by"`
DeliverTo string `json:"deliver_to"`
ShippingInstructions string `json:"shipping_instructions"`
CurrencySymbol string `json:"currency_symbol"`
CurrencyCode string `json:"currency_code"`
ShowGST bool `json:"show_gst"`
Subtotal float64 `json:"subtotal"`
GSTAmount float64 `json:"gst_amount"`
Total float64 `json:"total"`
LineItems []PurchaseOrderLineItemRequest `json:"line_items"`
OutputDir string `json:"output_dir"`
}
// GeneratePurchaseOrderPDF handles POST /go/pdf/generate-po
func GeneratePurchaseOrderPDF(w http.ResponseWriter, r *http.Request) {
var req PurchaseOrderPDFRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
return
}
if req.Title == "" || req.PrincipleName == "" {
http.Error(w, "title and principle_name are required", http.StatusBadRequest)
return
}
issueDate := time.Now()
if req.IssueDate != "" {
if parsed, err := time.Parse("2006-01-02", req.IssueDate); err == nil {
issueDate = parsed
}
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = os.Getenv("PDF_OUTPUT_DIR")
}
if outputDir == "" {
outputDir = "../php/app/webroot/pdf"
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
log.Printf("GeneratePurchaseOrderPDF: failed to create output dir: %v", err)
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
return
}
doc := &db.Document{ID: req.DocumentID}
po := &db.PurchaseOrder{
Title: req.Title,
PrincipleReference: req.PrincipleReference,
IssueDate: issueDate,
OrderedFrom: req.OrderedFrom,
DispatchBy: req.DispatchBy,
DeliverTo: req.DeliverTo,
ShippingInstructions: req.ShippingInstructions,
}
principle := &db.Principle{Name: req.PrincipleName}
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
for i, li := range req.LineItems {
lineItems[i] = db.GetLineItemsTableRow{
ItemNumber: li.ItemNumber,
Quantity: li.Quantity,
Title: li.Title,
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
}
}
data := &pdf.PurchaseOrderPDFData{
Document: doc,
PurchaseOrder: po,
Principle: principle,
LineItems: lineItems,
CurrencySymbol: req.CurrencySymbol,
CurrencyCode: req.CurrencyCode,
ShowGST: req.ShowGST,
Subtotal: req.Subtotal,
GSTAmount: req.GSTAmount,
Total: req.Total,
IssueDateString: req.IssueDateString,
}
filename, err := pdf.GeneratePurchaseOrderPDF(data, outputDir)
if err != nil {
log.Printf("GeneratePurchaseOrderPDF: failed to generate PDF: %v", err)
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
}
// GeneratePackingListPDF handles POST /go/pdf/generate-packinglist
func GeneratePackingListPDF(w http.ResponseWriter, r *http.Request) {
var req PackingListPDFRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
return
}
if req.CustomerName == "" {
http.Error(w, "customer_name is required", http.StatusBadRequest)
return
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = os.Getenv("PDF_OUTPUT_DIR")
}
if outputDir == "" {
outputDir = "../php/app/webroot/pdf"
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
log.Printf("GeneratePackingListPDF: failed to create output dir: %v", err)
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
return
}
// Reuse the invoice generator structure but label as PACKING LIST via DetailsBox
// Build minimal data shape
doc := &db.Document{ID: req.DocumentID}
cust := &db.Customer{Name: req.CustomerName}
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
for i, li := range req.LineItems {
lineItems[i] = db.GetLineItemsTableRow{
ItemNumber: li.ItemNumber,
Quantity: li.Quantity,
Title: li.Title,
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
}
}
data := &pdf.PackingListPDFData{
Document: doc,
Customer: cust,
Title: req.Title,
JobTitle: req.JobTitle,
IssueDate: req.IssueDate,
IssueDateString: req.IssueDateString,
ShipVia: req.ShipVia,
FOB: req.FOB,
LineItems: lineItems,
CurrencySymbol: req.CurrencySymbol,
CurrencyCode: req.CurrencyCode,
ShowGST: req.ShowGST,
}
filename, err := pdf.GeneratePackingListPDF(data, outputDir)
if err != nil {
log.Printf("GeneratePackingListPDF: failed to generate PDF: %v", err)
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
}
// GenerateOrderAckPDF handles POST /go/pdf/generate-orderack
func GenerateOrderAckPDF(w http.ResponseWriter, r *http.Request) {
var req OrderAckPDFRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
return
}
if req.CustomerName == "" {
http.Error(w, "customer_name is required", http.StatusBadRequest)
return
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = os.Getenv("PDF_OUTPUT_DIR")
}
if outputDir == "" {
outputDir = "../php/app/webroot/pdf"
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
log.Printf("GenerateOrderAckPDF: failed to create output dir: %v", err)
http.Error(w, "failed to prepare output directory", http.StatusInternalServerError)
return
}
doc := &db.Document{ID: req.DocumentID}
cust := &db.Customer{Name: req.CustomerName}
lineItems := make([]db.GetLineItemsTableRow, len(req.LineItems))
for i, li := range req.LineItems {
lineItems[i] = db.GetLineItemsTableRow{
ItemNumber: li.ItemNumber,
Quantity: li.Quantity,
Title: li.Title,
GrossUnitPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.UnitPrice), Valid: true},
GrossPrice: sql.NullString{String: fmt.Sprintf("%.2f", li.TotalPrice), Valid: true},
}
}
data := &pdf.OrderAckPDFData{
Document: doc,
Customer: cust,
Title: req.Title,
JobTitle: req.JobTitle,
IssueDate: req.IssueDate,
IssueDateString: req.IssueDateString,
ShipVia: req.ShipVia,
FOB: req.FOB,
EstimatedDelivery: req.EstimatedDelivery,
LineItems: lineItems,
CurrencySymbol: req.CurrencySymbol,
CurrencyCode: req.CurrencyCode,
ShowGST: req.ShowGST,
}
filename, err := pdf.GenerateOrderAckPDF(data, outputDir)
if err != nil {
log.Printf("GenerateOrderAckPDF: failed to generate PDF: %v", err)
http.Error(w, "failed to generate PDF", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"filename": filename})
}
// PackingListLineItemRequest reuses the invoice item shape
type PackingListLineItemRequest = InvoiceLineItemRequest
// PackingListPDFRequest payload
type PackingListPDFRequest struct {
DocumentID int32 `json:"document_id"`
Title string `json:"title"`
CustomerName string `json:"customer_name"`
JobTitle string `json:"job_title"`
IssueDate string `json:"issue_date"` // YYYY-MM-DD
IssueDateString string `json:"issue_date_string"` // formatted date
ShipVia string `json:"ship_via"`
FOB string `json:"fob"`
CurrencySymbol string `json:"currency_symbol"`
CurrencyCode string `json:"currency_code"`
ShowGST bool `json:"show_gst"`
LineItems []PackingListLineItemRequest `json:"line_items"`
OutputDir string `json:"output_dir"`
}
// OrderAckLineItemRequest reuses the invoice item shape
type OrderAckLineItemRequest = InvoiceLineItemRequest
// OrderAckPDFRequest payload
type OrderAckPDFRequest struct {
DocumentID int32 `json:"document_id"`
Title string `json:"title"`
CustomerName string `json:"customer_name"`
JobTitle string `json:"job_title"`
IssueDate string `json:"issue_date"` // YYYY-MM-DD
IssueDateString string `json:"issue_date_string"` // formatted date
ShipVia string `json:"ship_via"`
FOB string `json:"fob"`
EstimatedDelivery string `json:"estimated_delivery"`
CurrencySymbol string `json:"currency_symbol"`
CurrencyCode string `json:"currency_code"`
ShowGST bool `json:"show_gst"`
LineItems []OrderAckLineItemRequest `json:"line_items"`
OutputDir string `json:"output_dir"`
}
// CountPagesRequest payload for page counting
type CountPagesRequest struct {
FilePath string `json:"file_path"`
}
// CountPages handles POST /go/pdf/count-pages
// Returns JSON: {"page_count": <number>} or {"error": "<message>"}
func CountPages(w http.ResponseWriter, r *http.Request) {
var req CountPagesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
return
}
if req.FilePath == "" {
http.Error(w, "file_path is required", http.StatusBadRequest)
return
}
// Count pages in the PDF file
pageCount, err := pdf.CountPDFPages(req.FilePath)
if err != nil {
log.Printf("CountPages: error counting pages in %s: %v", req.FilePath, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"page_count": 0,
"error": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]int{"page_count": pageCount})
}

View file

@ -2,6 +2,7 @@ package pdf
import (
"fmt"
"math"
"path/filepath"
"github.com/jung-kurt/gofpdf"
@ -286,6 +287,316 @@ func (g *Generator) AddLineItemsTable(items []LineItem, currencySymbol string, s
}
}
// AddInvoiceShippingBillingBox adds the shipping/billing box with payment details matching old TCPDF layout
func (g *Generator) AddInvoiceShippingBillingBox(data interface{}) {
invoiceData := data.(*InvoicePDFData)
// Column widths: 30%, 30%, 3% spacer, 37%
pageWidth := 210.0 // A4 width in mm
margin := 10.0
contentWidth := pageWidth - 2*margin // ~190mm
soldToWidth := contentWidth * 0.30 // ~57mm
deliveryWidth := contentWidth * 0.30 // ~57mm
spacerWidth := contentWidth * 0.03 // ~5.7mm
invoiceDetailsWidth := contentWidth * 0.37 // ~70mm
// Row 1: Headers with gray background
g.pdf.SetFillColor(242, 242, 242) // Light gray
g.pdf.SetFont("Helvetica", "B", 10)
g.pdf.SetDrawColor(0, 0, 0)
g.pdf.SetLineWidth(0.5)
// "Sold To / Invoice Address:"
g.pdf.CellFormat(soldToWidth, 8, "Sold To / Invoice Address:", "1", 0, "C", true, 0, "")
// "Delivery Address:"
g.pdf.CellFormat(deliveryWidth, 8, "Delivery Address:", "1", 0, "C", true, 0, "")
// Spacer (no border)
g.pdf.SetDrawColor(255, 255, 255)
g.pdf.CellFormat(spacerWidth, 8, "", "0", 0, "C", false, 0, "")
g.pdf.SetDrawColor(0, 0, 0)
// Right column header spanning 2 rows - "MAKE PAYMENT TO:"
g.pdf.CellFormat(invoiceDetailsWidth, 8, "MAKE PAYMENT TO:", "1", 1, "C", true, 0, "")
// Row 2: Address content
g.pdf.SetFont("Helvetica", "", 9)
g.pdf.SetFillColor(255, 255, 255) // White background
// Bill To address (left column)
billToLines := len(invoiceData.BillTo)
maxHeight := float64(billToLines) * 4.5
startX := g.pdf.GetX()
g.pdf.MultiCell(soldToWidth, 4.5, invoiceData.BillTo, "1", "L", false)
leftY := g.pdf.GetY()
// Ship To address (middle column)
g.pdf.SetXY(startX+soldToWidth, g.pdf.GetY()-maxHeight)
g.pdf.MultiCell(deliveryWidth, 4.5, invoiceData.ShipTo, "1", "L", false)
// Spacer
g.pdf.SetXY(startX+soldToWidth+deliveryWidth, g.pdf.GetY()-maxHeight)
g.pdf.SetDrawColor(255, 255, 255)
g.pdf.CellFormat(spacerWidth, maxHeight, "", "0", 0, "C", false, 0, "")
g.pdf.SetDrawColor(0, 0, 0)
// Invoice details and payment info (right column)
g.pdf.SetXY(startX+soldToWidth+deliveryWidth+spacerWidth, g.pdf.GetY()-maxHeight)
g.pdf.SetFont("Helvetica", "B", 9)
paymentText := "CMC INVOICE#:\n" + invoiceData.Invoice.Title + "\n\n"
paymentText += "Date:\n" + invoiceData.IssueDateString + "\n\n"
paymentText += "Page:\n1\n\n"
// Add currency-specific bank details
bankDetails := g.getBankDetails(invoiceData.CurrencyCode)
for k, v := range bankDetails {
paymentText += k + ":\n" + v + "\n\n"
}
g.pdf.SetFont("Helvetica", "", 8)
g.pdf.MultiCell(invoiceDetailsWidth, 3.5, paymentText, "1", "L", false)
// Move to next line after the tallest cell
maxY := math.Max(leftY, g.pdf.GetY())
g.pdf.SetXY(startX, maxY)
g.pdf.Ln(2)
}
// getBankDetails returns currency-specific bank account information
func (g *Generator) getBankDetails(currencyCode string) map[string]string {
details := make(map[string]string)
switch currencyCode {
case "EUR":
details["Account"] = "06200015682004"
details["Branch"] = "06200"
details["SWIFT"] = "CTBAAU2S"
case "GBP":
details["Account"] = "06200015642694"
details["Branch"] = "06200"
details["SWIFT"] = "CTBAAU2S"
case "USD":
details["Account"] = "06200015681984"
details["Branch"] = "06200"
details["SWIFT"] = "CTBAAU2S"
default: // AUD
details["BSB"] = "062-458"
details["Account"] = "10067982"
details["SWIFT"] = "CTBAAU2S"
details["IBAN"] = "06245810067982"
}
return details
}
// AddInvoiceDetailsTable adds the order number, job, FOB, payment terms, ABN table
func (g *Generator) AddInvoiceDetailsTable(data interface{}) {
invoiceData := data.(*InvoicePDFData)
pageWidth := 210.0
margin := 10.0
contentWidth := pageWidth - 2*margin
colWidth := contentWidth / 5 // 5 equal columns
g.pdf.Ln(3)
// Header row with gray background
g.pdf.SetFillColor(242, 242, 242)
g.pdf.SetFont("Helvetica", "B", 9)
g.pdf.SetDrawColor(0, 0, 0)
g.pdf.SetLineWidth(0.5)
headers := []string{
"CUSTOMER ORDER NO",
"CMC JOB #",
"INCOTERMS 2010",
"PAYMENT TERMS",
"CUSTOMER ABN",
}
for _, header := range headers {
g.pdf.CellFormat(colWidth, 6, header, "1", 0, "C", true, 0, "")
}
g.pdf.Ln()
// Data row
g.pdf.SetFont("Helvetica", "", 9)
g.pdf.SetFillColor(255, 255, 255)
rowData := []string{
invoiceData.CustomerOrderNumber,
invoiceData.Invoice.Title,
invoiceData.FOB,
invoiceData.PaymentTerms,
invoiceData.CustomerABN,
}
for _, value := range rowData {
g.pdf.CellFormat(colWidth, 6, value, "1", 0, "C", false, 0, "")
}
g.pdf.Ln(2)
}
// AddInvoiceLineItemsTableWithShipping adds line items table with freight and totals
func (g *Generator) AddInvoiceLineItemsTableWithShipping(data interface{}) {
invoiceData := data.(*InvoicePDFData)
pageWidth := 210.0
margin := 10.0
contentWidth := pageWidth - 2*margin
// Column widths (variable for item no, then equal for others)
itemNoWidth := contentWidth * 0.10 // Item number column
qtyWidth := contentWidth * 0.08 // Qty column
descWidth := contentWidth * 0.42 // Description (larger)
unitPriceWidth := contentWidth * 0.20 // Unit Price
totalPriceWidth := contentWidth * 0.20 // Total Price
g.pdf.Ln(2)
g.pdf.SetFont("Helvetica", "B", 9)
g.pdf.SetFillColor(242, 242, 242)
g.pdf.SetDrawColor(0, 0, 0)
g.pdf.SetLineWidth(0.5)
// Table header
g.pdf.CellFormat(itemNoWidth, 6, "ITEM NO.", "1", 0, "C", true, 0, "")
g.pdf.CellFormat(qtyWidth, 6, "QTY", "1", 0, "C", true, 0, "")
g.pdf.CellFormat(descWidth, 6, "DESCRIPTION", "1", 0, "C", true, 0, "")
g.pdf.CellFormat(unitPriceWidth, 6, "UNIT PRICE", "1", 0, "C", true, 0, "")
g.pdf.CellFormat(totalPriceWidth, 6, "TOTAL PRICE", "1", 1, "C", true, 0, "")
// Currency row
g.pdf.SetFont("Helvetica", "", 8)
g.pdf.SetFillColor(255, 255, 255)
g.pdf.CellFormat(itemNoWidth, 4, "", "1", 0, "C", false, 0, "")
g.pdf.CellFormat(qtyWidth, 4, "", "1", 0, "C", false, 0, "")
g.pdf.CellFormat(descWidth, 4, "", "1", 0, "C", false, 0, "")
g.pdf.CellFormat(unitPriceWidth, 4, invoiceData.CurrencyCode, "1", 0, "C", false, 0, "")
g.pdf.CellFormat(totalPriceWidth, 4, invoiceData.CurrencyCode, "1", 1, "C", false, 0, "")
// Line items
g.pdf.SetFont("Helvetica", "", 9)
startY := g.pdf.GetY()
for _, item := range invoiceData.LineItems {
// Item number
g.pdf.CellFormat(itemNoWidth, 5, item.ItemNumber, "1", 0, "L", false, 0, "")
// Quantity
g.pdf.CellFormat(qtyWidth, 5, item.Quantity, "1", 0, "C", false, 0, "")
// Title and description (multi-line)
descText := item.Title
if item.Description != "" {
descText += "\n" + item.Description
}
if item.Option {
descText = "Option: " + descText
}
// Calculate description cell height based on content
descLines := len([]rune(descText))/50 + 1 // Rough estimation
descHeight := float64(descLines) * 4.5
currentX := g.pdf.GetX()
currentY := g.pdf.GetY()
g.pdf.MultiCell(descWidth, 4.5, descText, "1", "L", false)
// Back up to same Y as item no to place price cells
newY := g.pdf.GetY()
g.pdf.SetXY(currentX+descWidth, currentY)
// Unit Price (with discount breakdown if applicable)
unitPrice := item.GrossUnitPrice.String
netPrice := item.NetUnitPrice.String
discountPercent := item.DiscountPercent.String
discountAmount := item.DiscountAmountUnit.String
priceText := unitPrice
if discountPercent != "" && discountPercent != "0" {
priceText += "\nless " + discountPercent + "%*"
if discountAmount != "" {
priceText += "\n(-" + discountAmount + ")"
}
priceText += "\n=\n" + netPrice
}
g.pdf.MultiCell(unitPriceWidth, 4.5, priceText, "1", "R", false)
// Back up again for total price
g.pdf.SetXY(currentX+descWidth+unitPriceWidth, currentY)
// Total Price (with discount breakdown if applicable)
totalPrice := item.GrossPrice.String
netTotalPrice := item.NetPrice.String
discountTotal := item.DiscountAmountTotal.String
totalText := totalPrice
if discountPercent != "" && discountPercent != "0" {
totalText += "\nless " + discountPercent + "%*"
if discountTotal != "" {
totalText += "\n(-" + discountTotal + ")"
}
totalText += "\n=\n" + netTotalPrice
}
g.pdf.MultiCell(totalPriceWidth, 4.5, totalText, "1", "R", false)
// Move to the bottom of all cells in this row
g.pdf.SetXY(margin, newY)
}
currentY := g.pdf.GetY()
// FREIGHT DETAILS row
g.pdf.SetFillColor(242, 242, 242)
g.pdf.SetFont("Helvetica", "B", 9)
g.pdf.CellFormat(itemNoWidth+qtyWidth+descWidth, 6, "FREIGHT DETAILS:", "1", 0, "C", true, 0, "")
g.pdf.CellFormat(unitPriceWidth, 6, "SUBTOTAL", "1", 0, "C", true, 0, "")
g.pdf.CellFormat(totalPriceWidth, 6, formatCurrency(invoiceData.Subtotal, invoiceData.CurrencySymbol), "1", 1, "R", true, 0, "")
// Shipping details row (rowspan=2)
g.pdf.SetFont("Helvetica", "", 8)
g.pdf.SetFillColor(255, 255, 255)
currentX := g.pdf.GetX()
currentY = g.pdf.GetY()
// Shipping details spanning 3 columns and 2 rows
shippingText := invoiceData.ShippingDetails
if shippingText == "" {
shippingText = "TBA"
}
g.pdf.MultiCell(itemNoWidth+qtyWidth+descWidth, 12, shippingText, "1", "L", false)
// Move back to add GST row next to shipping details
g.pdf.SetXY(currentX+itemNoWidth+qtyWidth+descWidth, currentY)
g.pdf.SetFillColor(242, 242, 242)
g.pdf.SetFont("Helvetica", "B", 9)
g.pdf.CellFormat(unitPriceWidth, 6, "GST", "1", 0, "C", true, 0, "")
g.pdf.CellFormat(totalPriceWidth, 6, formatCurrency(invoiceData.GSTAmount, invoiceData.CurrencySymbol), "1", 1, "R", true, 0, "")
// TOTAL row
g.pdf.SetFillColor(242, 242, 242)
g.pdf.SetFont("Helvetica", "B", 9)
g.pdf.CellFormat(itemNoWidth+qtyWidth+descWidth, 6, "", "0", 0, "C", false, 0, "")
g.pdf.CellFormat(unitPriceWidth, 6, "TOTAL", "1", 0, "C", true, 0, "")
totalDisplay := formatCurrency(invoiceData.Total, invoiceData.CurrencySymbol)
g.pdf.CellFormat(totalPriceWidth, 6, totalDisplay, "1", 1, "R", true, 0, "")
g.pdf.Ln(2)
}
// formatCurrency formats a float as currency string
func formatCurrency(amount float64, symbol string) string {
return fmt.Sprintf("%s%.2f", symbol, amount)
}
// Save saves the PDF to a file
func (g *Generator) Save(filename string) error {
g.pdf.AliasNbPages("")

View file

@ -130,13 +130,25 @@ type InvoicePDFData struct {
LineItems []db.GetLineItemsTableRow
Currency interface{} // Currency data
CurrencySymbol string
CurrencyCode string
ShowGST bool
ShipVia string
FOB string
IssueDate time.Time
IssueDateString string
BillTo string
ShipTo string
ShippingDetails string
CustomerOrderNumber string
JobTitle string
PaymentTerms string
CustomerABN string
Subtotal float64
GSTAmount float64
Total float64
}
// GenerateInvoicePDF generates a PDF for an invoice
// GenerateInvoicePDF generates a PDF for an invoice matching old TCPDF layout
func GenerateInvoicePDF(data *InvoicePDFData, outputDir string) (string, error) {
gen := NewGenerator(outputDir)
@ -144,72 +156,35 @@ func GenerateInvoicePDF(data *InvoicePDFData, outputDir string) (string, error)
gen.AddPage()
gen.Page1Header()
// Extract data for details box
companyName := data.Customer.Name
emailTo := "" // TODO: Get from contact
attention := "" // TODO: Get from contact
fromName := "" // TODO: Get from user
fromEmail := "" // TODO: Get from user
invoiceNumber := data.Invoice.Title
// Title - TAX INVOICE
gen.pdf.SetFont("Helvetica", "B", 18)
gen.pdf.SetTextColor(0, 0, 0)
gen.pdf.CellFormat(0, 10, "TAX INVOICE", "", 1, "C", false, 0, "")
gen.pdf.Ln(3)
yourReference := "" // TODO: Get reference
issueDate := data.IssueDate.Format("2 January 2006")
// Shipping/Billing Box with payment details (matches old TCPDF layout)
gen.AddInvoiceShippingBillingBox(data)
// Add details box
gen.DetailsBox("INVOICE", companyName, emailTo, attention, fromName, fromEmail, invoiceNumber, yourReference, issueDate)
// Details table - CUSTOMER ORDER NO, JOB #, FOB, PAYMENT TERMS, ABN
gen.pdf.Ln(2)
gen.AddInvoiceDetailsTable(data)
// Add shipping details
gen.pdf.Ln(5)
gen.pdf.SetFont("Helvetica", "B", 10)
gen.pdf.CellFormat(30, 5, "Ship Via:", "", 0, "L", false, 0, "")
gen.pdf.SetFont("Helvetica", "", 10)
gen.pdf.CellFormat(60, 5, data.ShipVia, "", 1, "L", false, 0, "")
gen.pdf.Ln(3)
gen.pdf.SetFont("Helvetica", "B", 10)
gen.pdf.CellFormat(30, 5, "FOB:", "", 0, "L", false, 0, "")
gen.pdf.SetFont("Helvetica", "", 10)
gen.pdf.CellFormat(60, 5, data.FOB, "", 1, "L", false, 0, "")
// Line items table with freight and totals (matches old TCPDF layout)
gen.AddInvoiceLineItemsTableWithShipping(data)
gen.Page1Footer()
// Add line items page
gen.AddPage()
gen.pdf.SetFont("Helvetica", "B", 14)
gen.pdf.CellFormat(0, 10, "INVOICE DETAILS", "", 1, "C", false, 0, "")
gen.pdf.Ln(5)
// Add terms and conditions page
gen.AddTermsAndConditions()
// Convert line items
pdfItems := make([]LineItem, len(data.LineItems))
for i, item := range data.LineItems {
unitPrice := 0.0
totalPrice := 0.0
// Parse prices
if item.GrossUnitPrice.Valid {
fmt.Sscanf(item.GrossUnitPrice.String, "%f", &unitPrice)
// Generate filename and save
filename := fmt.Sprintf("%s.pdf", data.Invoice.Title)
if err := gen.Save(filename); err != nil {
return "", err
}
if item.GrossPrice.Valid {
fmt.Sscanf(item.GrossPrice.String, "%f", &totalPrice)
}
pdfItems[i] = LineItem{
ItemNumber: item.ItemNumber,
Quantity: item.Quantity,
Title: item.Title,
UnitPrice: unitPrice,
TotalPrice: totalPrice,
}
}
// Add line items table
gen.AddLineItemsTable(pdfItems, data.CurrencySymbol, data.ShowGST)
// Generate filename
filename := fmt.Sprintf("%s.pdf", invoiceNumber)
// Save PDF
err := gen.Save(filename)
return filename, err
return filename, nil
}
// PurchaseOrderPDFData contains all data needed to generate a purchase order PDF

Binary file not shown.