From 7f4da2f606a47e2f5da2698600c49f5cbb67b706 Mon Sep 17 00:00:00 2001 From: Hazem Krimi Date: Thu, 14 Aug 2025 20:00:21 +0100 Subject: [PATCH] wip: improve invoice generation logic --- cmd/root.go | 2 +- go.mod | 2 +- go.sum | 10 +++++ internal/api/invoice.go | 85 ++++++++++++++++++++++++++++++++++- internal/api/routes.go | 1 + internal/lib/utils.go | 56 +++++++++-------------- internal/models/invoice.go | 19 ++++++++ internal/types/invoice.go | 6 +-- migrations/20250814173803.sql | 23 ++++++++++ migrations/atlas.sum | 3 +- 10 files changed, 165 insertions(+), 42 deletions(-) create mode 100644 migrations/20250814173803.sql diff --git a/cmd/root.go b/cmd/root.go index 97f507a..76f3c3b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,7 +26,7 @@ to quickly create a Cobra application.`, // Uncomment the following line if your bare application // has an action associated with it: Run: func(cmd *cobra.Command, args []string) { - dir, err := lib.GetConfigDirectory() + dir, err := lib.GetConfigDirectoryPath() if err != nil { log.Fatal(err) diff --git a/go.mod b/go.mod index f71b149..ae73ac7 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-playground/validator/v10 v10.26.0 github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 + github.com/johnfercher/maroto/v2 v2.3.1 github.com/labstack/echo-contrib v0.17.4 github.com/labstack/echo/v4 v4.13.4 github.com/spf13/cobra v1.9.1 @@ -38,7 +39,6 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/johnfercher/go-tree v1.0.5 // indirect - github.com/johnfercher/maroto/v2 v2.3.1 // indirect github.com/jung-kurt/gofpdf v1.16.2 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/go.sum b/go.sum index 818f751..ee93ba7 100644 --- a/go.sum +++ b/go.sum @@ -104,6 +104,10 @@ github.com/johnfercher/maroto/v2 v2.3.1/go.mod h1:/LfW6AQGZzsG6xUixcfyxkKztDoszd github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= +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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= @@ -142,6 +146,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN 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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= @@ -149,6 +155,8 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0= +github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -258,6 +266,8 @@ golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/api/invoice.go b/internal/api/invoice.go index 245322a..408f6ed 100644 --- a/internal/api/invoice.go +++ b/internal/api/invoice.go @@ -85,9 +85,26 @@ func (api *API) CreateInvoiceHandler(context echo.Context) error { return types.Error{Code: http.StatusInternalServerError, Cause: errors.New("Unexpected error getting User."), Messages: []string{"Unexpected error getting User!"}} } - lib.GenerateInvoice(invoice, user, client) + path, err := lib.GenerateInvoice(invoice, user, client) - return context.Attachment(fmt.Sprintf("invoices/%s.pdf", invoice.ID), fmt.Sprintf("invoices/%s.pdf", invoice.ID)) + if err != nil { + return types.Error{Code: http.StatusInternalServerError, Cause: err, Messages: []string{"Unexpected error generating Invoice!"}} + } + + invoiceFileName := fmt.Sprintf("%s.pdf", invoice.ID) + invoiceId, err := uuid.Parse(invoice.ID) + + if err != nil { + return types.Error{Code: http.StatusBadRequest, Messages: []string{"Could not parse Invoice ID!"}} + } + + err = api.db.UpdateInvoicePDF(userId, invoiceId, path, &invoice) + + if err != nil { + return types.Error{Code: http.StatusInternalServerError, Cause: err, Messages: []string{"Unexpected error generating Invoice!"}} + } + + return context.Attachment(path, invoiceFileName) } func (api *API) GetAllItemsHandler(context echo.Context) error { @@ -172,6 +189,70 @@ func (api *API) GetInvoiceHandler(context echo.Context) error { return context.JSON(http.StatusOK, invoice) } +func (api *API) DownloadInvoiceHandler(context echo.Context) error { + userId, err := uuid.Parse(context.Get("id").(string)) + + if err != nil { + return types.Error{Code: http.StatusInternalServerError, Cause: errors.New("Session ID not found after authorization."), Messages: []string{"Unexpected error getting User!"}} + } + + id, err := uuid.Parse(context.Param("id")) + + if err != nil { + return types.Error{Code: http.StatusBadRequest, Messages: []string{"ID is required to download an Invoice!"}} + } + + var invoice types.Invoice + + if err := api.db.GetInvoiceById(userId, id, &invoice); err != nil { + return types.Error{Code: http.StatusNotFound, Messages: []string{"Invoice not found!"}} + } + + if invoice.PDF != "" { + invoiceFileName := fmt.Sprintf("%s.pdf", invoice.ID) + return context.Attachment(invoice.PDF, invoiceFileName) + } + + var user types.User + + if err := api.db.GetUserById(userId, &user); err != nil { + return types.Error{Code: http.StatusInternalServerError, Cause: errors.New("Unexpected error getting User."), Messages: []string{"Unexpected error getting User!"}} + } + + clientId, err := uuid.Parse(invoice.ClientID) + + if err != nil { + return types.Error{Code: http.StatusInternalServerError, Cause: errors.New("Invalid Client ID."), Messages: []string{"Unexpected error getting User!"}} + } + + var client types.Client + + if err := api.db.GetClientById(userId, clientId, &client); err != nil { + return types.Error{Code: http.StatusInternalServerError, Cause: errors.New("Unexpected error getting Client."), Messages: []string{"Unexpected error getting User!"}} + } + + path, err := lib.GenerateInvoice(invoice, user, client) + + if err != nil { + return types.Error{Code: http.StatusInternalServerError, Cause: err, Messages: []string{"Unexpected error generating Invoice!"}} + } + + invoiceFileName := fmt.Sprintf("%s.pdf", invoice.ID) + invoiceId, err := uuid.Parse(invoice.ID) + + if err != nil { + return types.Error{Code: http.StatusBadRequest, Messages: []string{"Could not parse Invoice ID!"}} + } + + err = api.db.UpdateInvoicePDF(userId, invoiceId, path, &invoice) + + if err != nil { + return types.Error{Code: http.StatusInternalServerError, Cause: err, Messages: []string{"Unexpected error generating Invoice!"}} + } + + return context.Attachment(path, invoiceFileName) +} + func (api *API) UpdateItemHandler(context echo.Context) error { userId, err := uuid.Parse(context.Get("id").(string)) diff --git a/internal/api/routes.go b/internal/api/routes.go index 6fd9318..5b977e8 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -32,6 +32,7 @@ func (api *API) InvoiceRoutes() { invoices.POST("/", api.CreateInvoiceHandler) invoices.POST("/:id/items/", api.CreateItemHandler) invoices.GET("/:id/", api.GetInvoiceHandler) + invoices.GET("/:id/download/", api.DownloadInvoiceHandler) invoices.GET("/:id/items/", api.GetAllItemsHandler) invoices.GET("/items/:id/", api.GetItemHandler) invoices.PUT("/:id/", api.UpdateInvoiceHandler) diff --git a/internal/lib/utils.go b/internal/lib/utils.go index 7c870c4..cdc2e1a 100644 --- a/internal/lib/utils.go +++ b/internal/lib/utils.go @@ -9,7 +9,6 @@ import ( "github.com/google/uuid" "github.com/gorilla/sessions" "github.com/johnfercher/maroto/v2" - "github.com/johnfercher/maroto/v2/pkg/components/code" "github.com/johnfercher/maroto/v2/pkg/components/col" "github.com/johnfercher/maroto/v2/pkg/components/image" "github.com/johnfercher/maroto/v2/pkg/components/row" @@ -25,7 +24,7 @@ import ( "github.com/hazemKrimi/crimson-vault/internal/types" ) -func GetConfigDirectory() (string, error) { +func GetConfigDirectoryPath() (string, error) { home, err := os.UserHomeDir() if err != nil { @@ -172,7 +171,7 @@ func getTransactions(items []types.Item) []core.Row { col.New(3), text.NewCol(4, item.Name, props.Text{Size: 8, Align: align.Center}), text.NewCol(2, fmt.Sprintf("%d", item.Quantity), props.Text{Size: 8, Align: align.Center}), - text.NewCol(3, fmt.Sprintf("%d", item.Price), props.Text{Size: 8, Align: align.Center}), + text.NewCol(3, fmt.Sprintf("%.2f", item.Price), props.Text{Size: 8, Align: align.Center}), ) if i%2 == 0 { gray := getGrayColor() @@ -203,7 +202,7 @@ func getTransactions(items []types.Item) []core.Row { return rows } -func GenerateInvoice(invoice types.Invoice, user types.User, client types.Client) error { +func GenerateInvoice(invoice types.Invoice, user types.User, client types.Client) (string, error) { cfg := config.NewBuilder().WithPageNumber().WithLeftMargin(10).WithRightMargin(10).WithTopMargin(15).Build() darkGray := getDarkGrayColor() mrt := maroto.New(cfg) @@ -212,23 +211,23 @@ func GenerateInvoice(invoice types.Invoice, user types.User, client types.Client err := m.RegisterHeader(getPageHeader(client, user.Logo)) if err != nil { - return err + return "", err } err = m.RegisterFooter(getPageFooter(user)) if err != nil { - return err + return "", err } - m.AddRows(text.NewRow(10, "Invoice ABC123456789", props.Text{ + m.AddRows(text.NewRow(10, fmt.Sprintf("Invoice %s", invoice.ID), props.Text{ Top: 3, Style: fontstyle.Bold, Align: align.Center, })) m.AddRow(7, - text.NewCol(3, "Transactions", props.Text{ + text.NewCol(3, "Items", props.Text{ Top: 1.5, Size: 9, Style: fontstyle.Bold, @@ -239,43 +238,32 @@ func GenerateInvoice(invoice types.Invoice, user types.User, client types.Client m.AddRows(getTransactions(invoice.Items)...) - m.AddRow(15, - col.New(6).Add( - code.NewBar("5123.151231.512314.1251251.123215", props.Barcode{ - Percent: 0, - Proportion: props.Proportion{ - Width: 20, - Height: 2, - }, - }), - text.New("5123.151231.512314.1251251.123215", props.Text{ - Top: 12, - Family: "", - Style: fontstyle.Bold, - Size: 9, - Align: align.Center, - }), - ), - col.New(6), - ) - document, err := m.Generate() if err != nil { - return err + return "", err } - err = document.Save(fmt.Sprintf("invoices/%s.pdf", invoice.ID)) + configDir, err := GetConfigDirectoryPath() if err != nil { - return err + return "", err } - err = document.GetReport().Save(fmt.Sprintf("invoices/%s.txt", invoice.ID)) + invoicesDir := filepath.Join(configDir, user.Username, client.ID) + + if err := os.MkdirAll(invoicesDir, os.ModePerm); err != nil { + return "", err + } + + invoiceFileName := fmt.Sprintf("%s.pdf", invoice.ID) + invoicePath := filepath.Join(invoicesDir, invoiceFileName) + + err = document.Save(invoicePath) if err != nil { - return err + return "", err } - return nil + return invoicePath, nil } diff --git a/internal/models/invoice.go b/internal/models/invoice.go index f19900b..03ae4f8 100644 --- a/internal/models/invoice.go +++ b/internal/models/invoice.go @@ -1,6 +1,7 @@ package models import ( + "errors" "time" "github.com/google/uuid" @@ -170,6 +171,24 @@ func (db *DB) UpdateInvoice(userId, id uuid.UUID, body types.UpdateInvoiceReques return nil } +func (db *DB) UpdateInvoicePDF(userId, id uuid.UUID, pdf string, invoice *types.Invoice) error { + if pdf == "" { + return errors.New("PDF path is empty!") + } + + result := db.instance.Where("user_id = ?", userId).Where("id = ?", id).First(invoice) + + if result.Error != nil { + return result.Error + } + + result = db.instance.Model(invoice).Updates(types.Invoice{ + PDF: pdf, + }) + + return nil +} + func (db *DB) DeleteItem(userId, id uuid.UUID) error { result := db.instance.Unscoped().Where("user_id = ?", userId).Where("id = ?", id).Delete(&types.Item{}) diff --git a/internal/types/invoice.go b/internal/types/invoice.go index 5b6bb9b..16e8847 100644 --- a/internal/types/invoice.go +++ b/internal/types/invoice.go @@ -30,7 +30,7 @@ type Item struct { UserID string `json:"userId" gorm:"type:varchar(255)"` Name string `json:"name"` Type string `json:"type"` - Price uint32 `json:"price"` + Price float32 `json:"price"` Quantity uint32 `json:"quantity"` Tax uint32 `json:"tax"` } @@ -79,7 +79,7 @@ type CreateItemRequestBody struct { Name string `json:"name" validate:"alpha,required"` Type string `json:"type" validate:"alpha,required"` Quantity uint32 `json:"quantity" validate:"number,required"` - Price uint32 `json:"price" validate:"number,required"` + Price float32 `json:"price" validate:"number,required"` Tax uint32 `json:"tax" validate:"number,omitempty"` } @@ -87,7 +87,7 @@ type UpdateItemRequestBody struct { Name string `json:"name" validate:"alpha,omitempty"` Type string `json:"type" validate:"alpha,omitempty"` Quantity uint32 `json:"quantity" validate:"number,omitempty"` - Price uint32 `json:"price" validate:"number,omitempty"` + Price float32 `json:"price" validate:"number,omitempty"` Tax uint32 `json:"tax" validate:"number,omitempty"` } diff --git a/migrations/20250814173803.sql b/migrations/20250814173803.sql new file mode 100644 index 0000000..019a234 --- /dev/null +++ b/migrations/20250814173803.sql @@ -0,0 +1,23 @@ +-- Disable the enforcement of foreign-keys constraints +PRAGMA foreign_keys = off; +-- Create "new_items" table +CREATE TABLE `new_items` ( + `id` varchar NULL, + `invoice_id` varchar NULL, + `user_id` varchar NULL, + `name` text NULL, + `type` text NULL, + `price` real NULL, + `quantity` integer NULL, + `tax` integer NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_invoices_items` FOREIGN KEY (`invoice_id`) REFERENCES `invoices` (`id`) ON UPDATE NO ACTION ON DELETE NO ACTION +); +-- Copy rows from old table "items" to new temporary table "new_items" +INSERT INTO `new_items` (`id`, `invoice_id`, `user_id`, `name`, `type`, `price`, `quantity`, `tax`) SELECT `id`, `invoice_id`, `user_id`, `name`, `type`, `price`, `quantity`, `tax` FROM `items`; +-- Drop "items" table after copying rows +DROP TABLE `items`; +-- Rename temporary table "new_items" to "items" +ALTER TABLE `new_items` RENAME TO `items`; +-- Enable back the enforcement of foreign-keys constraints +PRAGMA foreign_keys = on; diff --git a/migrations/atlas.sum b/migrations/atlas.sum index d171957..566796b 100644 --- a/migrations/atlas.sum +++ b/migrations/atlas.sum @@ -1,5 +1,6 @@ -h1:dDD/MWKqjFIkCAuR4i/9LtH4ibg9wZR03FApuHHYCos= +h1:wDAImuaIK9lhzbnQ3++MNijLAxUnoaLdlysqqYjOfoU= 20250611102124.sql h1:HkDgtWXUxfAR9gIObTMzP98pVEIakerASGGSufW695k= 20250616102420.sql h1:7C1kEskaDwdHmQOu3t48oZcky0WaNiFeIugJcJFkjKM= 20250616150439.sql h1:nx8CvH5om7lbeEezo7roNcTV+f0agch0gBjYxKtTDn8= 20250616151658.sql h1:O4hbYFj4bwMDML0mR9PtSn7GUbXQVCbh+lGkwTNBwnU= +20250814173803.sql h1:xRBhN15wPcJJOfzzPuMoUJsUqEYNCdLmEBiYUJBF1FA=